openzoo 0.50.94 → 0.50.96
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 +28 -3
- package/bin/openzoo.js +2 -26
- package/lib/aoe.js +8 -2
- package/lib/cursorbackend.js +42 -17
- package/lib/grokbotweb.js +10 -1
- package/lib/launch.js +18 -24
- package/lib/models.js +0 -30
- package/lib/ozSpendChip.js +187 -29
- package/lib/proxy.js +129 -87
- package/lib/setup.js +12 -13
- package/lib/stripeOnramp.js +50 -0
- package/package.json +1 -1
- package/lib/websearch.js +0 -31
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 THIS version on :8402 (steals stale)
|
|
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,7 +16,8 @@
|
|
|
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 } from 'node:fs';
|
|
19
|
+
import { existsSync, readdirSync as fsReaddir, readFileSync } from 'node:fs';
|
|
20
|
+
import { execSync } from 'node:child_process';
|
|
20
21
|
import { homedir, platform } from 'node:os';
|
|
21
22
|
import { dirname, join, sep } from 'node:path';
|
|
22
23
|
import { fileURLToPath } from 'node:url';
|
|
@@ -50,10 +51,34 @@ if (!occ) {
|
|
|
50
51
|
const PROXY_PORT = Number(process.env.OPENZOO_PROXY_PORT || 8402);
|
|
51
52
|
const PROXY_URL = `http://localhost:${PROXY_PORT}/v1`;
|
|
52
53
|
|
|
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
|
+
|
|
53
70
|
async function proxyUp() {
|
|
54
71
|
try {
|
|
55
72
|
const r = await fetch(`http://localhost:${PROXY_PORT}/v1/info`, { signal: AbortSignal.timeout(1500) });
|
|
56
|
-
|
|
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;
|
|
57
82
|
} catch { return false; }
|
|
58
83
|
}
|
|
59
84
|
|
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>]');
|
|
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,20 +344,7 @@ 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
|
-
|
|
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
|
-
}
|
|
347
|
+
const system = si !== -1 ? process.argv[si + 1] : '';
|
|
361
348
|
const { PayClient } = await import('../lib/pay.js');
|
|
362
349
|
const { config } = await import('../lib/config.js');
|
|
363
350
|
const client = new PayClient();
|
|
@@ -402,17 +389,6 @@ async function main() {
|
|
|
402
389
|
case '-h':
|
|
403
390
|
console.log(HELP);
|
|
404
391
|
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
|
-
}
|
|
416
392
|
default:
|
|
417
393
|
console.error(`unknown command: ${cmd}\n`);
|
|
418
394
|
console.log(HELP);
|
package/lib/aoe.js
CHANGED
|
@@ -287,9 +287,15 @@ 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
|
-
|
|
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}`);
|
|
292
293
|
} 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
|
+
}
|
|
293
299
|
const { pid, logPath } = startDetachedProxy({ tunnel: flags.has('--tunnel') });
|
|
294
300
|
let up = false;
|
|
295
301
|
for (let i = 0; i < 40 && !up; i++) {
|
package/lib/cursorbackend.js
CHANGED
|
@@ -44,7 +44,8 @@ import {
|
|
|
44
44
|
readWakeups, writeWakeups, shapeWakeup, parseWakeupEvery, wantsWakeupCron,
|
|
45
45
|
DEFAULT_WAKEUP_PROMPT, addDeletedIds, filterDeleted,
|
|
46
46
|
} from './grokbotAccount.js';
|
|
47
|
-
import { formatSpendFooter, mergeTurnProof } from './spendProof.js';
|
|
47
|
+
import { spendChipLabel, formatSpendFooter, mergeTurnProof } from './spendProof.js';
|
|
48
|
+
import { spendLinesOnly, writeSpendHud } from './ozSpendChip.js';
|
|
48
49
|
import { zooModelIds, zooModelRow, mediaKindOf } from './models.js';
|
|
49
50
|
import { prefixVisitorRichText } from './grokbotweb.js';
|
|
50
51
|
import {
|
|
@@ -1880,7 +1881,15 @@ async function zooSpendOverlay(data) {
|
|
|
1880
1881
|
const bal = Number.isFinite(wallet) && wallet > 0.004
|
|
1881
1882
|
? wallet
|
|
1882
1883
|
: (Number.isFinite(credit) && credit > 0.004 ? credit : null);
|
|
1883
|
-
|
|
1884
|
+
const label = spent > 0.00005 ? spendChipLabel({
|
|
1885
|
+
billedUsd: x.billedUsd,
|
|
1886
|
+
directUsd: x.directUsd,
|
|
1887
|
+
spent,
|
|
1888
|
+
would,
|
|
1889
|
+
saved,
|
|
1890
|
+
pct,
|
|
1891
|
+
}) : '';
|
|
1892
|
+
const footer = formatSpendFooter({
|
|
1884
1893
|
billedUsd: x.billedUsd,
|
|
1885
1894
|
directUsd: x.directUsd,
|
|
1886
1895
|
spent,
|
|
@@ -1890,6 +1899,12 @@ async function zooSpendOverlay(data) {
|
|
|
1890
1899
|
balance: bal,
|
|
1891
1900
|
x402: x,
|
|
1892
1901
|
});
|
|
1902
|
+
const body = spendLinesOnly(footer) || (spent > 0.00005
|
|
1903
|
+
? `spent $${spent.toFixed(4)} · OpenRouter would $${would.toFixed(4)} · saved $${saved.toFixed(4)} (${pct.toFixed(0)}%)`
|
|
1904
|
+
: '');
|
|
1905
|
+
const state = { spent, would, saved, pct, label, billedUsd: x.billedUsd, directUsd: x.directUsd, balance: bal, body };
|
|
1906
|
+
try { writeSpendHud(state); } catch { /* */ }
|
|
1907
|
+
return state;
|
|
1893
1908
|
}
|
|
1894
1909
|
|
|
1895
1910
|
const MODELS_PATH = path.join(os.homedir(), '.openzoo', 'grokbot-models.json');
|
|
@@ -2941,7 +2956,7 @@ async function zooComplete(prompt, log, agentId, parsed = {}, opts = {}) {
|
|
|
2941
2956
|
'This is a public visitor chat. You do not have filesystem, shell, or local-exec access on the host Mac.',
|
|
2942
2957
|
'Reply in chat only. Do not claim you will write files, run commands, or use local tools.',
|
|
2943
2958
|
'Each human line is prefixed with that visitor\'s shortname and a colon, like "rex: hello". Different shortnames are different people. Address them by that name.',
|
|
2944
|
-
'
|
|
2959
|
+
'Do not write spend totals, solscan links, memos, or ::oz-spend:: into the reply. The host paints a HUD pill.',
|
|
2945
2960
|
'Prior turns of THIS Grok Bot chat are in the messages below. Do not claim the thread starts blank or that earlier questions did not arrive.',
|
|
2946
2961
|
].join(' ')
|
|
2947
2962
|
: [
|
|
@@ -2984,8 +2999,8 @@ async function zooComplete(prompt, log, agentId, parsed = {}, opts = {}) {
|
|
|
2984
2999
|
'Never claim you lack filesystem access or local-exec. If a tool errors, report the error.',
|
|
2985
3000
|
'After using tools you MUST still write a normal chat reply: what you did, file paths written, and what to open. Empty content is a bug. A new user message cancels this turn — leave a visible reply before that happens.',
|
|
2986
3001
|
'Do not stop mid-task. Do not write "Stopped on research", "nothing to open yet", "no app files written this turn", or "say go again". Keep using tools until the files the user asked for exist on disk THIS turn. Summarize only after those writes succeed.',
|
|
2987
|
-
'
|
|
2988
|
-
'The operator wallet pays every zoo call via x402 (TOKEN/USDC on Solana; SOL gas is sponsored). If
|
|
3002
|
+
'Do not write spend totals, solscan links, memos, or ::oz-spend:: into the reply. The host paints a HUD pill.',
|
|
3003
|
+
'The operator wallet pays every zoo call via x402 (TOKEN/USDC on Solana; SOL gas is sponsored). If the spend HUD is visible or a prior turn paid, the call WAS paid. Do not say you have no money, cannot afford tools, or need the human to fund you. A 402/underfunded tool error is a host retry — continue the job.',
|
|
2989
3004
|
'Prior turns of THIS Grok Bot chat are in the messages below. Do not claim the thread starts blank or that earlier questions did not arrive.',
|
|
2990
3005
|
].join(' '),
|
|
2991
3006
|
},
|
|
@@ -3075,17 +3090,22 @@ async function zooComplete(prompt, log, agentId, parsed = {}, opts = {}) {
|
|
|
3075
3090
|
if (r.status === 402) {
|
|
3076
3091
|
const raw = zooTextFromMessage(data?.choices?.[0]?.message, data)
|
|
3077
3092
|
|| data?.error?.message
|
|
3078
|
-
|| 'openzoo
|
|
3093
|
+
|| 'openzoo payment required (HTTP 402).';
|
|
3094
|
+
data.error = data.error || {};
|
|
3095
|
+
data.error.message = raw;
|
|
3079
3096
|
try {
|
|
3080
|
-
const { withOnrampLink } = await import('./stripeOnramp.js');
|
|
3081
|
-
|
|
3082
|
-
|
|
3083
|
-
|
|
3084
|
-
|
|
3085
|
-
|
|
3086
|
-
|
|
3087
|
-
|
|
3088
|
-
|
|
3097
|
+
const { withOnrampLink, isFundInstruction } = await import('./stripeOnramp.js');
|
|
3098
|
+
// Settle/upstream 402s keep the real message. Whop copy-paste only
|
|
3099
|
+
// when this is a genuine empty-wallet / fund-me instruction.
|
|
3100
|
+
if (isFundInstruction(raw)) {
|
|
3101
|
+
const { loadOrCreateWallet } = await import('./wallet.js');
|
|
3102
|
+
const w = loadOrCreateWallet();
|
|
3103
|
+
const usd = Number(String(raw).match(/≈\$([0-9.]+)/)?.[1]);
|
|
3104
|
+
data.error.message = await withOnrampLink(raw, {
|
|
3105
|
+
solana: w.keypair.publicKey.toBase58(),
|
|
3106
|
+
usd,
|
|
3107
|
+
});
|
|
3108
|
+
}
|
|
3089
3109
|
} catch { /* keep proxy copy */ }
|
|
3090
3110
|
}
|
|
3091
3111
|
return { r, data };
|
|
@@ -3232,7 +3252,7 @@ async function zooComplete(prompt, log, agentId, parsed = {}, opts = {}) {
|
|
|
3232
3252
|
if (lastData && typeof lastData === 'object') {
|
|
3233
3253
|
lastData.x402 = mergeTurnProof(turnX402, lastData);
|
|
3234
3254
|
}
|
|
3235
|
-
try {
|
|
3255
|
+
try { await zooSpendOverlay(lastData); } catch { /* HUD dump; never append to the reply */ }
|
|
3236
3256
|
return { text, data: lastData };
|
|
3237
3257
|
}
|
|
3238
3258
|
|
|
@@ -3297,6 +3317,12 @@ async function handleHijackedPodHttp(req, res, full, body, log) {
|
|
|
3297
3317
|
// Revive: the minutely cron (scripts/revive-bots.sh) calls this after it has
|
|
3298
3318
|
// made sure the hijack + app are alive. Every non-group bot gets a wakeup
|
|
3299
3319
|
// timer if it lost one; nothing is spawned, nothing already armed is touched.
|
|
3320
|
+
if (name === 'ozSpend') {
|
|
3321
|
+
let state = { spent: 0, would: 0, saved: 0, pct: 0, label: '' };
|
|
3322
|
+
try { state = await zooSpendOverlay(null); } catch { /* */ }
|
|
3323
|
+
jsonSend(res, { ok: true, ...state });
|
|
3324
|
+
return true;
|
|
3325
|
+
}
|
|
3300
3326
|
if (name === 'ozRevive') {
|
|
3301
3327
|
let parsed = {};
|
|
3302
3328
|
try { parsed = JSON.parse(String(body || '{}')); } catch { parsed = {}; }
|
|
@@ -3603,7 +3629,6 @@ async function handleHijackedPodHttp(req, res, full, body, log) {
|
|
|
3603
3629
|
text = `model set to ${id}`;
|
|
3604
3630
|
}
|
|
3605
3631
|
}
|
|
3606
|
-
try { text += await zooSpendOverlay({}); } catch { /* */ }
|
|
3607
3632
|
} else {
|
|
3608
3633
|
const members = groupMemberIds(agentId);
|
|
3609
3634
|
if (members.length) {
|
package/lib/grokbotweb.js
CHANGED
|
@@ -18,7 +18,7 @@ import { exec } from 'node:child_process';
|
|
|
18
18
|
import { config } from './config.js';
|
|
19
19
|
import { readHouseRoster } from './grokbotAccount.js';
|
|
20
20
|
import { ingestUpload, lookupUpload } from './grokbotUploads.js';
|
|
21
|
-
import { spendChipSource } from './ozSpendChip.js';
|
|
21
|
+
import { spendChipSource, sessionSpendState } from './ozSpendChip.js';
|
|
22
22
|
|
|
23
23
|
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
24
24
|
const SHIM_PATH = path.join(HERE, 'grokbotweb-shim.js');
|
|
@@ -763,6 +763,15 @@ export async function startGrokBotWeb(opts = {}) {
|
|
|
763
763
|
|
|
764
764
|
const server = http.createServer((req, res) => {
|
|
765
765
|
const urlPath = decodeURIComponent((req.url || '/').split('?')[0]);
|
|
766
|
+
if (urlPath === '/oz-spend') {
|
|
767
|
+
writeHead(res, 200, {
|
|
768
|
+
'content-type': 'application/json',
|
|
769
|
+
'cache-control': 'no-store',
|
|
770
|
+
'access-control-allow-origin': '*',
|
|
771
|
+
}, req);
|
|
772
|
+
res.end(JSON.stringify({ ok: true, ...sessionSpendState() }));
|
|
773
|
+
return;
|
|
774
|
+
}
|
|
766
775
|
if (urlPath === '/oz-health') {
|
|
767
776
|
writeHead(res, 200, { 'content-type': 'application/json' }, req);
|
|
768
777
|
res.end(JSON.stringify({
|
package/lib/launch.js
CHANGED
|
@@ -190,18 +190,21 @@ 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
|
+
|
|
193
195
|
export async function launchClaude(argv) {
|
|
194
|
-
//
|
|
195
|
-
// below must follow the port we actually bound.
|
|
196
|
+
// NEVER hop. :8402 is the product. startProxy steals a stale listener.
|
|
196
197
|
let base = `http://localhost:${config.port}/v1`;
|
|
197
|
-
// AUTO-START THE PROXY. One command should just work — if nothing is listening
|
|
198
|
-
//
|
|
199
|
-
// foreground below), rather than making the user run `npx openzoo` first.
|
|
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.
|
|
200
200
|
let up = false;
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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
|
+
}
|
|
205
208
|
if (!up) {
|
|
206
209
|
// NEVER GO SILENT DURING STARTUP. silent:true routes the proxy's own lines
|
|
207
210
|
// to ~/.openzoo/proxy.log so payment receipts cannot corrupt Claude Code's
|
|
@@ -218,7 +221,6 @@ export async function launchClaude(argv) {
|
|
|
218
221
|
tick.unref?.();
|
|
219
222
|
const done = (msg) => { clearInterval(tick); process.stderr.write(`\r\x1b[2Kopenzoo: ${msg}\n`); };
|
|
220
223
|
try {
|
|
221
|
-
const { startProxy } = await import('./proxy.js');
|
|
222
224
|
await startProxy({ silent: true, autoTunnel: true });
|
|
223
225
|
} catch (err) {
|
|
224
226
|
// An exception here used to surface as an eternal spinner. Say what broke.
|
|
@@ -227,9 +229,6 @@ export async function launchClaude(argv) {
|
|
|
227
229
|
console.error(' try: OPENZOO_NO_TUNNEL=1 npx openzoo claude (skips the cloudflared download)');
|
|
228
230
|
process.exit(1);
|
|
229
231
|
}
|
|
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.
|
|
233
232
|
base = `http://localhost:${config.port}/v1`;
|
|
234
233
|
// PROBE /v1/info, NOT /v1/models. `models` is PROXIED UPSTREAM, so on a
|
|
235
234
|
// network with a bad path to the gateway the local proxy is listening and
|
|
@@ -580,17 +579,12 @@ export async function launchClaude(argv) {
|
|
|
580
579
|
}
|
|
581
580
|
|
|
582
581
|
export async function launchHarness(cmd, args) {
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
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);
|
|
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`;
|
|
594
588
|
}
|
|
595
589
|
|
|
596
590
|
const env = claudeZooEnv(process.env, { base });
|
package/lib/models.js
CHANGED
|
@@ -168,39 +168,9 @@ 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
|
-
|
|
198
171
|
export function resolveModel(requested, ids) {
|
|
199
172
|
// Virtual router id — never family-match, never steal via OPENZOO_DEFAULT_MODEL.
|
|
200
173
|
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;
|
|
204
174
|
// Bare Anthropic / Claude Code ids are never live on Fly/OpenRouter
|
|
205
175
|
// (`claude-opus-5` → 500 unknown model). Rewrite even on a catalog miss
|
|
206
176
|
// or if a gateway row lists the bare name — the request must not leave
|
package/lib/ozSpendChip.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Cafe spend
|
|
2
|
+
* Cafe/Grok Bot spend HUD. Live totals come from /oz-spend and /api/ozSpend;
|
|
3
|
+
* leftover ::oz-spend:: footers in old transcripts still fold into a chip.
|
|
3
4
|
*
|
|
4
5
|
* Cafe injects this via grokbotweb-shim concatenation. Grok Bot.app cannot
|
|
5
6
|
* be patched (asar integrity), so `openzoo bot` launches Chromium with a
|
|
@@ -14,18 +15,82 @@ import { spendChipLabel } from './spendProof.js';
|
|
|
14
15
|
|
|
15
16
|
export const GROKBOT_CDP_PORT = Number(process.env.OZ_GROKBOT_CDP_PORT || 9444);
|
|
16
17
|
|
|
17
|
-
|
|
18
|
-
|
|
18
|
+
export function spendHudPath(home = os.homedir()) {
|
|
19
|
+
return path.join(home, '.openzoo', 'spend-hud.json');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function writeSpendHud(state, home = os.homedir()) {
|
|
23
|
+
try {
|
|
24
|
+
const spent = Number(state?.spent) || 0;
|
|
25
|
+
const would = Number(state?.would) || 0;
|
|
26
|
+
const saved = Number(state?.saved) || 0;
|
|
27
|
+
const pct = Number(state?.pct) || 0;
|
|
28
|
+
const payload = {
|
|
29
|
+
spent,
|
|
30
|
+
would,
|
|
31
|
+
saved,
|
|
32
|
+
pct,
|
|
33
|
+
label: String(state?.label || ''),
|
|
34
|
+
body: String(state?.body || ''),
|
|
35
|
+
billedUsd: state?.billedUsd ?? null,
|
|
36
|
+
updatedAt: Date.now(),
|
|
37
|
+
};
|
|
38
|
+
fs.mkdirSync(path.dirname(spendHudPath(home)), { recursive: true, mode: 0o700 });
|
|
39
|
+
fs.writeFileSync(spendHudPath(home), JSON.stringify(payload), { mode: 0o600 });
|
|
40
|
+
return true;
|
|
41
|
+
} catch {
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function readSpendHud(home = os.homedir()) {
|
|
47
|
+
try {
|
|
48
|
+
const j = JSON.parse(fs.readFileSync(spendHudPath(home), 'utf8'));
|
|
49
|
+
return j && typeof j === 'object' ? j : null;
|
|
50
|
+
} catch {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function sessionTotals(home) {
|
|
19
56
|
try {
|
|
20
57
|
const s = JSON.parse(fs.readFileSync(path.join(home, '.openzoo', 'session.json'), 'utf8'));
|
|
21
58
|
const spent = Number(s.spentUsd || s.spendUsd || 0);
|
|
22
59
|
const would = Number(s.directUsd || 0);
|
|
23
60
|
const saved = Number(s.savedUsd != null ? s.savedUsd : Math.max(0, would - spent));
|
|
24
|
-
|
|
25
|
-
|
|
61
|
+
const pct = would > 0 ? (100 * saved) / would : 0;
|
|
62
|
+
const label = spent > 0.00005 ? spendChipLabel({ spent, would, saved, pct }) : '';
|
|
63
|
+
return { spent, would, saved, pct, label };
|
|
26
64
|
} catch {
|
|
27
|
-
return '';
|
|
65
|
+
return { spent: 0, would: 0, saved: 0, pct: 0, label: '' };
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function sessionBody(sess) {
|
|
70
|
+
if (!(sess.spent > 0.00005)) return '';
|
|
71
|
+
return `spent $${sess.spent.toFixed(4)} · OpenRouter would $${sess.would.toFixed(4)} · saved $${sess.saved.toFixed(4)} (${Math.round(sess.pct)}%)`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Session totals + last-call dump for the HUD pill. */
|
|
75
|
+
export function sessionSpendState(home = os.homedir()) {
|
|
76
|
+
const sess = sessionTotals(home);
|
|
77
|
+
const hud = readSpendHud(home);
|
|
78
|
+
if (hud && (hud.label || hud.body)) {
|
|
79
|
+
return {
|
|
80
|
+
spent: Number(hud.spent) || sess.spent,
|
|
81
|
+
would: Number(hud.would) || sess.would,
|
|
82
|
+
saved: Number(hud.saved) || sess.saved,
|
|
83
|
+
pct: Number(hud.pct) || sess.pct,
|
|
84
|
+
label: String(hud.label || sess.label || ''),
|
|
85
|
+
body: String(hud.body || sessionBody(sess)),
|
|
86
|
+
billedUsd: hud.billedUsd ?? null,
|
|
87
|
+
};
|
|
28
88
|
}
|
|
89
|
+
return { ...sess, body: sessionBody(sess), billedUsd: null };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function sessionSpendLabel(home = os.homedir()) {
|
|
93
|
+
return sessionSpendState(home).label;
|
|
29
94
|
}
|
|
30
95
|
|
|
31
96
|
export function grokBotChromiumArgs(port = GROKBOT_CDP_PORT) {
|
|
@@ -159,9 +224,13 @@ function ozEnsureSpendCss() {
|
|
|
159
224
|
}
|
|
160
225
|
s.textContent = [
|
|
161
226
|
'.oz-spend{margin:.55rem 0 0;font-size:12px;color:inherit;opacity:.82;max-width:36em}',
|
|
162
|
-
'#oz-spend-
|
|
227
|
+
'#oz-spend-hud{opacity:1;max-width:min(28em,calc(100vw - 24px));pointer-events:auto}',
|
|
228
|
+
'#oz-spend-hud>summary{cursor:grab;background:#e85d1c;color:#fff;font-weight:600;font-size:13px;',
|
|
229
|
+
'border:0;box-shadow:0 4px 18px rgba(0,0,0,.5)}',
|
|
230
|
+
'#oz-spend-hud>.oz-spend-body{background:rgba(20,20,22,.94);color:#f4f4f5;padding:8px 10px;',
|
|
231
|
+
'border-radius:10px;margin-top:6px}',
|
|
163
232
|
'.oz-spend>summary{cursor:help;list-style:none;display:inline-flex;align-items:center;gap:.35rem;',
|
|
164
|
-
'padding:
|
|
233
|
+
'padding:6px 12px;border-radius:999px;border:1px solid rgba(255,255,255,.18);white-space:nowrap;',
|
|
165
234
|
'max-width:100%;overflow:hidden;text-overflow:ellipsis}',
|
|
166
235
|
'.oz-spend>summary::-webkit-details-marker{display:none}',
|
|
167
236
|
'.oz-spend-body{white-space:pre-wrap;margin:.55rem 0 0;font-size:11px;line-height:1.45;opacity:.88;overflow-wrap:anywhere}',
|
|
@@ -213,11 +282,19 @@ export function stripSpendFromText(s) {
|
|
|
213
282
|
return t;
|
|
214
283
|
}
|
|
215
284
|
|
|
285
|
+
function ozInComposer(el) {
|
|
286
|
+
if (!el) return false;
|
|
287
|
+
if (el.tagName === 'TEXTAREA' || el.tagName === 'INPUT' || el.isContentEditable) return true;
|
|
288
|
+
return !!(el.closest && el.closest('textarea, input, [contenteditable="true"]'));
|
|
289
|
+
}
|
|
290
|
+
|
|
216
291
|
function ozBlankSpendIn(root) {
|
|
292
|
+
if (ozInComposer(root)) return;
|
|
217
293
|
const w = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, null);
|
|
218
294
|
while (w.nextNode()) {
|
|
219
295
|
const tn = w.currentNode;
|
|
220
296
|
if (tn.parentElement && tn.parentElement.closest && tn.parentElement.closest('.oz-spend')) continue;
|
|
297
|
+
if (ozInComposer(tn.parentElement)) continue;
|
|
221
298
|
const next = stripSpendFromText(tn.nodeValue || '');
|
|
222
299
|
if (next !== tn.nodeValue) tn.nodeValue = next;
|
|
223
300
|
}
|
|
@@ -228,6 +305,7 @@ function ozHideSpendLeftovers() {
|
|
|
228
305
|
for (let i = 0; i < nodes.length; i += 1) {
|
|
229
306
|
const el = nodes[i];
|
|
230
307
|
if (el.closest && el.closest('.oz-spend')) continue;
|
|
308
|
+
if (ozInComposer(el)) continue;
|
|
231
309
|
if (el.querySelector && el.querySelector('.oz-spend')) continue;
|
|
232
310
|
const vis = ozVisibleSpendText(el);
|
|
233
311
|
if (!spendOnlyText(vis)) continue;
|
|
@@ -290,10 +368,11 @@ function ozCollapseSpend() {
|
|
|
290
368
|
for (let i = 0; i < nodes.length; i += 1) {
|
|
291
369
|
const el = nodes[i];
|
|
292
370
|
if (el.closest && el.closest('.oz-spend')) continue;
|
|
371
|
+
if (ozInComposer(el)) continue;
|
|
293
372
|
const t = ozVisibleSpendText(el);
|
|
294
373
|
if (!/::oz-spend::|this call \$|spent \$/i.test(t)) continue;
|
|
295
374
|
const host = ozSpendHost(el);
|
|
296
|
-
if (!host || seen.has(host)) continue;
|
|
375
|
+
if (!host || seen.has(host) || ozInComposer(host)) continue;
|
|
297
376
|
seen.add(host);
|
|
298
377
|
hosts.push(host);
|
|
299
378
|
}
|
|
@@ -302,10 +381,11 @@ function ozCollapseSpend() {
|
|
|
302
381
|
const vis = ozVisibleSpendText(host);
|
|
303
382
|
const split = splitSpendText(vis);
|
|
304
383
|
if (!split || !split.body || split.body.length < 12) continue;
|
|
384
|
+
if (split.summary) window.__OZ_SPEND_LAST__ = split.summary;
|
|
305
385
|
ozEnsureSpendCss();
|
|
306
386
|
if (spendOnlyText(vis)) {
|
|
307
387
|
const prev = ozPreviousMessageCard(host);
|
|
308
|
-
if (prev && prev !== host) {
|
|
388
|
+
if (prev && prev !== host && !ozInComposer(prev)) {
|
|
309
389
|
ozAttachSpendChip(prev, split);
|
|
310
390
|
host.setAttribute('data-oz-spend-hide', '1');
|
|
311
391
|
continue;
|
|
@@ -318,19 +398,44 @@ function ozCollapseSpend() {
|
|
|
318
398
|
ozEnsureFloatSpend();
|
|
319
399
|
}
|
|
320
400
|
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
const
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
if (
|
|
327
|
-
|
|
401
|
+
/** Live HUD label: latest folded chip, then a read-only canvas scan, then inject snapshot. */
|
|
402
|
+
function ozFloatLabel() {
|
|
403
|
+
const pills = document.querySelectorAll('.oz-spend:not(#oz-spend-hud):not(#oz-spend-float) summary');
|
|
404
|
+
if (pills.length) {
|
|
405
|
+
const lab = String(pills[pills.length - 1].textContent || '').replace(/^ⓘ\s*/, '').trim();
|
|
406
|
+
if (lab) {
|
|
407
|
+
window.__OZ_SPEND_LAST__ = lab;
|
|
408
|
+
return lab;
|
|
409
|
+
}
|
|
328
410
|
}
|
|
411
|
+
try {
|
|
412
|
+
const nodes = document.querySelectorAll('[class*="sand-message"]');
|
|
413
|
+
for (let i = nodes.length - 1; i >= 0; i -= 1) {
|
|
414
|
+
if (ozInComposer(nodes[i])) continue;
|
|
415
|
+
const vis = ozVisibleSpendText(nodes[i]);
|
|
416
|
+
if (!/::oz-spend::|this call \$|spent \$/i.test(vis)) continue;
|
|
417
|
+
const split = splitSpendText(vis);
|
|
418
|
+
if (split && split.summary) {
|
|
419
|
+
window.__OZ_SPEND_LAST__ = split.summary;
|
|
420
|
+
return split.summary;
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
} catch (e) {}
|
|
424
|
+
if (window.__OZ_SPEND_LAST__) return String(window.__OZ_SPEND_LAST__);
|
|
425
|
+
return String(window.__OZ_SESSION_SPEND__ || '').trim();
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
function ozEnsureFloatSpend() {
|
|
429
|
+
const leftover = document.getElementById('oz-spend-float');
|
|
430
|
+
if (leftover) leftover.remove();
|
|
431
|
+
const label = ozFloatLabel() || String(window.__OZ_SESSION_SPEND__ || '').trim() || 'openzoo';
|
|
432
|
+
let el = document.getElementById('oz-spend-hud');
|
|
329
433
|
ozEnsureSpendCss();
|
|
330
434
|
if (!el) {
|
|
331
435
|
el = document.createElement('details');
|
|
332
|
-
el.id = 'oz-spend-
|
|
436
|
+
el.id = 'oz-spend-hud';
|
|
333
437
|
el.className = 'oz-spend';
|
|
438
|
+
el.open = true;
|
|
334
439
|
const sum = document.createElement('summary');
|
|
335
440
|
const body = document.createElement('div');
|
|
336
441
|
body.className = 'oz-spend-body';
|
|
@@ -343,6 +448,9 @@ function ozEnsureFloatSpend() {
|
|
|
343
448
|
ozDragFloat(el);
|
|
344
449
|
const sum = el.querySelector('summary');
|
|
345
450
|
if (sum) sum.textContent = 'ⓘ ' + label;
|
|
451
|
+
const bodyEl = el.querySelector('.oz-spend-body');
|
|
452
|
+
const dump = String(window.__OZ_SPEND_BODY__ || '').trim();
|
|
453
|
+
if (bodyEl && dump) bodyEl.textContent = dump;
|
|
346
454
|
}
|
|
347
455
|
|
|
348
456
|
function ozSavedFloatPos() {
|
|
@@ -357,9 +465,15 @@ function ozPlaceFloat(el) {
|
|
|
357
465
|
if (!el) return;
|
|
358
466
|
el.style.position = 'fixed';
|
|
359
467
|
el.style.zIndex = '2147483646';
|
|
360
|
-
el.style.opacity = '
|
|
468
|
+
el.style.opacity = '1';
|
|
469
|
+
el.style.pointerEvents = 'auto';
|
|
470
|
+
const vw = Math.max(320, Number(window.innerWidth) || 800);
|
|
471
|
+
const vh = Math.max(240, Number(window.innerHeight) || 600);
|
|
361
472
|
const saved = ozSavedFloatPos();
|
|
362
|
-
|
|
473
|
+
const onScreen = saved
|
|
474
|
+
&& saved.left >= 0 && saved.top >= 0
|
|
475
|
+
&& saved.left < vw - 24 && saved.top < vh - 24;
|
|
476
|
+
if (onScreen) {
|
|
363
477
|
el.style.left = Math.max(8, saved.left) + 'px';
|
|
364
478
|
el.style.top = Math.max(8, saved.top) + 'px';
|
|
365
479
|
el.style.right = 'auto';
|
|
@@ -368,8 +482,8 @@ function ozPlaceFloat(el) {
|
|
|
368
482
|
}
|
|
369
483
|
el.style.left = 'auto';
|
|
370
484
|
el.style.right = '16px';
|
|
371
|
-
el.style.top = '
|
|
372
|
-
el.style.bottom = '
|
|
485
|
+
el.style.top = 'auto';
|
|
486
|
+
el.style.bottom = '88px';
|
|
373
487
|
}
|
|
374
488
|
|
|
375
489
|
function ozDragFloat(el) {
|
|
@@ -414,31 +528,71 @@ function ozDragFloat(el) {
|
|
|
414
528
|
}, true);
|
|
415
529
|
}
|
|
416
530
|
|
|
531
|
+
function ozLabelFromInfo(j) {
|
|
532
|
+
if (!j || typeof j !== 'object') return '';
|
|
533
|
+
if (typeof j.label === 'string' && j.label.trim()) return j.label.trim();
|
|
534
|
+
const spent = Number(j.spentUsd ?? j.spendUsd ?? j.spent ?? 0);
|
|
535
|
+
const would = Number(j.directUsd ?? j.would ?? 0);
|
|
536
|
+
const saved = Number(j.savedUsd != null ? j.savedUsd : (j.saved != null ? j.saved : Math.max(0, would - spent)));
|
|
537
|
+
if (!(spent > 0.00005)) return '';
|
|
538
|
+
return labelFromSpendBody(
|
|
539
|
+
'spent $' + spent.toFixed(4) + ' OpenRouter would $' + would.toFixed(4) + ' saved $' + saved.toFixed(4),
|
|
540
|
+
'',
|
|
541
|
+
);
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
function ozPollSpend() {
|
|
545
|
+
const urls = ['/oz-spend', '/api/ozSpend', 'https://127.0.0.1:8443/api/ozSpend', 'http://127.0.0.1:8402/v1/info'];
|
|
546
|
+
(async () => {
|
|
547
|
+
for (let i = 0; i < urls.length; i += 1) {
|
|
548
|
+
try {
|
|
549
|
+
const r = await fetch(urls[i], { signal: AbortSignal.timeout(1500) });
|
|
550
|
+
if (!r || !r.ok) continue;
|
|
551
|
+
const j = await r.json();
|
|
552
|
+
const lab = ozLabelFromInfo(j);
|
|
553
|
+
if (!lab) continue;
|
|
554
|
+
window.__OZ_SESSION_SPEND__ = lab;
|
|
555
|
+
if (typeof j.body === 'string' && j.body.trim()) window.__OZ_SPEND_BODY__ = j.body.trim();
|
|
556
|
+
ozEnsureFloatSpend();
|
|
557
|
+
return;
|
|
558
|
+
} catch (e) {}
|
|
559
|
+
}
|
|
560
|
+
})();
|
|
561
|
+
}
|
|
562
|
+
|
|
417
563
|
function ozWatchSpend() {
|
|
418
564
|
let t = 0;
|
|
565
|
+
const inComposer = () => ozInComposer(document.activeElement);
|
|
419
566
|
const run = () => {
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
567
|
+
try {
|
|
568
|
+
if (inComposer()) {
|
|
569
|
+
ozEnsureSpendCss();
|
|
570
|
+
ozEnsureFloatSpend();
|
|
571
|
+
} else {
|
|
572
|
+
ozCollapseSpend();
|
|
573
|
+
}
|
|
574
|
+
} catch (e) {}
|
|
423
575
|
};
|
|
424
|
-
const debounced = () => { clearTimeout(t); t = setTimeout(run,
|
|
576
|
+
const debounced = () => { clearTimeout(t); t = setTimeout(run, 400); };
|
|
425
577
|
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', run);
|
|
426
578
|
else run();
|
|
427
579
|
try {
|
|
428
580
|
const mo = new MutationObserver(debounced);
|
|
429
|
-
const start = () => { if (document.body) mo.observe(document.body, { childList: true, subtree:
|
|
581
|
+
const start = () => { if (document.body) mo.observe(document.body, { childList: true, subtree: true }); };
|
|
430
582
|
if (document.body) start();
|
|
431
583
|
else document.addEventListener('DOMContentLoaded', start);
|
|
432
584
|
} catch (e) {}
|
|
433
585
|
try { window.addEventListener('resize', debounced); } catch (e) {}
|
|
586
|
+
try { setInterval(() => { run(); ozPollSpend(); }, 2000); } catch (e) {}
|
|
587
|
+
try { ozPollSpend(); } catch (e) {}
|
|
434
588
|
}
|
|
435
589
|
|
|
436
590
|
export function spendChipSource() {
|
|
437
591
|
return [
|
|
438
592
|
'(function ozSpendChip(){',
|
|
439
593
|
"'use strict';",
|
|
440
|
-
'if (window.__OZ_SPEND_CHIP__ ===
|
|
441
|
-
'window.__OZ_SPEND_CHIP__ =
|
|
594
|
+
'if (window.__OZ_SPEND_CHIP__ === 16) return;',
|
|
595
|
+
'window.__OZ_SPEND_CHIP__ = 16;',
|
|
442
596
|
chipUsd.toString(),
|
|
443
597
|
labelFromSpendBody.toString(),
|
|
444
598
|
spendLinesOnly.toString(),
|
|
@@ -448,15 +602,19 @@ export function spendChipSource() {
|
|
|
448
602
|
ozSpendHost.toString(),
|
|
449
603
|
ozVisibleSpendText.toString(),
|
|
450
604
|
stripSpendFromText.toString(),
|
|
605
|
+
ozInComposer.toString(),
|
|
451
606
|
ozBlankSpendIn.toString(),
|
|
452
607
|
ozHideSpendLeftovers.toString(),
|
|
453
608
|
ozPreviousMessageCard.toString(),
|
|
454
609
|
ozAttachSpendChip.toString(),
|
|
455
610
|
ozCollapseSpend.toString(),
|
|
611
|
+
ozFloatLabel.toString(),
|
|
456
612
|
ozEnsureFloatSpend.toString(),
|
|
457
613
|
ozSavedFloatPos.toString(),
|
|
458
614
|
ozPlaceFloat.toString(),
|
|
459
615
|
ozDragFloat.toString(),
|
|
616
|
+
ozLabelFromInfo.toString(),
|
|
617
|
+
ozPollSpend.toString(),
|
|
460
618
|
ozWatchSpend.toString(),
|
|
461
619
|
'ozWatchSpend();',
|
|
462
620
|
'})();',
|
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.
|
|
@@ -38,19 +38,89 @@ 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.
|
|
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. */
|
|
42
45
|
export function killListen(port, run = execSync) {
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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);
|
|
49
52
|
}
|
|
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
|
+
}));
|
|
53
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;
|
|
112
|
+
}
|
|
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 */ }
|
|
122
|
+
}
|
|
123
|
+
return false;
|
|
54
124
|
}
|
|
55
125
|
|
|
56
126
|
// THE SHIM IS A FACILITATOR, NOT A MIDDLEBOX.
|
|
@@ -511,9 +581,17 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
|
|
|
511
581
|
const self = viaTunnel && tunnelGate?.publicUrl
|
|
512
582
|
? `${tunnelGate.publicUrl}/v1`
|
|
513
583
|
: `http://localhost:${config.port}/v1`;
|
|
514
|
-
res.writeHead(200, {
|
|
584
|
+
res.writeHead(200, {
|
|
585
|
+
'content-type': 'application/json',
|
|
586
|
+
'access-control-allow-origin': '*',
|
|
587
|
+
});
|
|
588
|
+
const { version: ozVersion } = JSON.parse(
|
|
589
|
+
readFileSync(new URL('../package.json', import.meta.url), 'utf8'),
|
|
590
|
+
);
|
|
515
591
|
res.end(JSON.stringify({
|
|
516
592
|
youAreTalkingTo: 'openzoo proxy',
|
|
593
|
+
version: ozVersion,
|
|
594
|
+
solana: client.address,
|
|
517
595
|
yourEndpoint: self,
|
|
518
596
|
reachedVia: viaTunnel ? 'public tunnel' : 'localhost',
|
|
519
597
|
publicTunnel: tunnelGate?.publicUrl ? `${tunnelGate.publicUrl}/v1` : null,
|
|
@@ -618,43 +696,10 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
|
|
|
618
696
|
const isPaidPost = req.method === 'POST'
|
|
619
697
|
&& /\/(chat\/completions|completions|messages|responses)$/.test(rawPath);
|
|
620
698
|
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
|
-
}
|
|
636
699
|
if (isPaidPost) {
|
|
637
700
|
servedRequests += 1;
|
|
638
701
|
say(`\n<- request #${servedRequests} from ${(req.headers['user-agent'] || 'unknown').slice(0, 40)}`);
|
|
639
702
|
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 */ }
|
|
658
703
|
}
|
|
659
704
|
|
|
660
705
|
// Retry of a body we answered seconds ago? Serve the cached completion —
|
|
@@ -825,11 +870,7 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
|
|
|
825
870
|
say(`credit -> $${x.billedUsd.toFixed(6)} · session $${sessionSpent.toFixed(6)}`);
|
|
826
871
|
}
|
|
827
872
|
if (data?.object === 'chat.completion') {
|
|
828
|
-
if (
|
|
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) {
|
|
873
|
+
if (paid && receipt) {
|
|
833
874
|
attachX402Proof(data, {
|
|
834
875
|
tx: receipt.tx,
|
|
835
876
|
memo: receipt.memo || accept?.extra?.memo,
|
|
@@ -866,14 +907,13 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
|
|
|
866
907
|
rememberSpend();
|
|
867
908
|
say(`credit -> $${x.billedUsd.toFixed(6)} · session $${sessionSpent.toFixed(6)}`);
|
|
868
909
|
};
|
|
869
|
-
// A 402 AFTER we attempted payment is a SETTLEMENT failure, not a quote
|
|
870
|
-
//
|
|
871
|
-
//
|
|
872
|
-
// retry with a fresh 402. Relaying that
|
|
873
|
-
//
|
|
874
|
-
//
|
|
910
|
+
// A 402 AFTER we attempted payment is a SETTLEMENT failure, not a quote.
|
|
911
|
+
// The client-side balance check is advisory, so a funded wallet can still
|
|
912
|
+
// fail on-chain / at the facilitator, and the gateway answers the paid
|
|
913
|
+
// retry with a fresh 402. Relaying that as "wallet underfunded" + Whop
|
|
914
|
+
// copy-paste blamed burners that already paid. Surface the gateway's
|
|
915
|
+
// real reason; only prepend fund-me copy on genuine insufficient_funds.
|
|
875
916
|
if (response.status === 402) {
|
|
876
|
-
let quoted = '';
|
|
877
917
|
let usd;
|
|
878
918
|
let q402 = null;
|
|
879
919
|
try { q402 = await response.clone().json(); } catch { q402 = null; }
|
|
@@ -890,18 +930,21 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
|
|
|
890
930
|
return;
|
|
891
931
|
}
|
|
892
932
|
try {
|
|
893
|
-
|
|
894
|
-
usd =
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
933
|
+
usd = Number(q402?.accepts?.[0]?.extra?.billedUsd);
|
|
934
|
+
if (!Number.isFinite(usd)) usd = undefined;
|
|
935
|
+
} catch { usd = undefined; }
|
|
936
|
+
const copy = settleFailCopy(q402);
|
|
937
|
+
// paid:true → never "wallet underfunded", never Whop unless the
|
|
938
|
+
// gateway itself named insufficient_funds. UnderfundedError (empty
|
|
939
|
+
// wallet preflight) is handled in the catch below.
|
|
940
|
+
let msg = copy.message;
|
|
941
|
+
const wantOnramp = copy.code === 'insufficient_funds'
|
|
942
|
+
|| (!paid && isFundInstruction(copy.reason, copy));
|
|
943
|
+
if (wantOnramp) {
|
|
944
|
+
msg = await withOnrampLink(msg, { solana: client.address, usd, code: copy.code });
|
|
945
|
+
}
|
|
946
|
+
log(/ties to your account/i.test(msg) ? 'onramp: whop + copy-paste solana' : `402: ${msg.slice(0, 140)}`);
|
|
947
|
+
jsonErr(res, paid ? copy.status : 402, msg);
|
|
905
948
|
return;
|
|
906
949
|
}
|
|
907
950
|
await relay(res, response, meterStreamed);
|
|
@@ -934,11 +977,18 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
|
|
|
934
977
|
// OPENZOO_BIND=0.0.0.0 AND a tunnel token, so that port stays gated exactly
|
|
935
978
|
// like the public tunnel path.
|
|
936
979
|
const bindHost = process.env.OPENZOO_BIND || '127.0.0.1';
|
|
937
|
-
// SELF-HEAL
|
|
938
|
-
//
|
|
939
|
-
//
|
|
940
|
-
//
|
|
941
|
-
|
|
980
|
+
// SELF-HEAL :8402. Kill whoever is on THIS port, then bind it. Never hop to
|
|
981
|
+
// 8403 — that was the second-burner. Every subcommand that calls startProxy
|
|
982
|
+
// (openzoo, claude, bot, web, cursor, aoe, grok, tunnel) goes through here.
|
|
983
|
+
// Reusing a leftover listener left a stale PayClient serving $0 after a
|
|
984
|
+
// TOKEN top-up, and an old npx cache answering /v1 with no version.
|
|
985
|
+
{
|
|
986
|
+
const pids = killListen(config.port);
|
|
987
|
+
if (pids.length) {
|
|
988
|
+
say(`openzoo: killed proxy on :${config.port} (pids ${pids.join(',')})`);
|
|
989
|
+
await new Promise((r) => setTimeout(r, 400));
|
|
990
|
+
}
|
|
991
|
+
}
|
|
942
992
|
for (let attempt = 0; ; attempt++) {
|
|
943
993
|
try {
|
|
944
994
|
await new Promise((resolve, reject) => {
|
|
@@ -948,20 +998,12 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
|
|
|
948
998
|
});
|
|
949
999
|
break;
|
|
950
1000
|
} catch (e) {
|
|
951
|
-
if (e?.code !== 'EADDRINUSE' || attempt >=
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
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}`);
|
|
1001
|
+
if (e?.code !== 'EADDRINUSE' || attempt >= 8) throw e;
|
|
1002
|
+
const pids = killListen(config.port);
|
|
1003
|
+
if (pids.length) say(`openzoo: killed proxy on :${config.port} (pids ${pids.join(',')})`);
|
|
1004
|
+
await new Promise((r) => setTimeout(r, 300));
|
|
962
1005
|
}
|
|
963
1006
|
}
|
|
964
|
-
if (config.port !== wanted) say(`openzoo: listening on :${config.port} (:${wanted} was busy)`);
|
|
965
1007
|
|
|
966
1008
|
// AUTO-PREPAY. Paying on-chain per call is where the latency lives: credit
|
|
967
1009
|
// 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 ? 'occupied (steal unless this exact version)' : '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,9 +245,17 @@ 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
|
-
|
|
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)...`);
|
|
251
259
|
started = await startProxy({ silent: true, autoTunnel: true });
|
|
252
260
|
publicUrl = started?.publicUrl ?? null;
|
|
253
261
|
tunnelKey = started?.tunnelToken ?? null;
|
|
@@ -298,15 +306,6 @@ export async function setupEditor(which, target) {
|
|
|
298
306
|
console.log(' most common cause here: no working IPv6 route (we already force');
|
|
299
307
|
console.log(' --edge-ip-version 4). check: npx openzoo tunnel for the raw log.');
|
|
300
308
|
}
|
|
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 */ }
|
|
310
309
|
}
|
|
311
310
|
// What the EDITOR is configured with. Localhost only as a last resort, and
|
|
312
311
|
// said out loud, because it will fail with the private-networks error.
|
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,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "openzoo",
|
|
3
|
-
"version": "0.50.
|
|
3
|
+
"version": "0.50.96",
|
|
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",
|
package/lib/websearch.js
DELETED
|
@@ -1,31 +0,0 @@
|
|
|
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
|
-
}
|