openzoo 0.50.95 → 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 +23 -60
- package/lib/models.js +0 -30
- package/lib/ozSpendChip.js +187 -29
- package/lib/proxy.js +129 -120
- 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
|
@@ -164,30 +164,7 @@ export function claudeZooEnv(baseEnv = process.env, { base, port } = {}) {
|
|
|
164
164
|
// Correct here for the same reason as everything else in this block: the
|
|
165
165
|
// proxy binds the prefix and forwards a bounded tail, so the request that
|
|
166
166
|
// leaves this machine stays small no matter how long the conversation runs.
|
|
167
|
-
|
|
168
|
-
// OPT-IN NOW, BECAUSE THE PRECONDITION IS NOT REAL.
|
|
169
|
-
//
|
|
170
|
-
// Everything above is correct ONLY IF something bounds the body that leaves
|
|
171
|
-
// this machine. Two comments here named that something — `spillTranscript`,
|
|
172
|
-
// and the knobs `OPENZOO_TAIL_MAX_CHARS` / `OPENZOO_KEEP_TAIL_MSGS`. None of
|
|
173
|
-
// the three exists: `grep -rn spillTranscript` matches nothing but these
|
|
174
|
-
// comments, in this package and in the gateway. Checked 2026-09-03.
|
|
175
|
-
//
|
|
176
|
-
// So the shipped behaviour was: compaction off, ceiling raised to 1M, and
|
|
177
|
-
// NOTHING trimming the transcript — Claude Code accumulates without limit and
|
|
178
|
-
// re-sends the whole thing every turn. The gateway's leCore cannot rescue it
|
|
179
|
-
// either: an agent body is few-and-huge, so `msgs.length <= KEEP_TAIL` leaves
|
|
180
|
-
// nothing "older than the live window", and what bulk there is sits in the
|
|
181
|
-
// system block and first user turn, both deliberately never spilled.
|
|
182
|
-
//
|
|
183
|
-
// MEASURED on a user's session: 20 calls, $13.86, `spilled 0/20`, 1.0159x vs
|
|
184
|
-
// direct — paying almost exactly retail to send an ever-growing transcript.
|
|
185
|
-
//
|
|
186
|
-
// Until a real bound ships, default to Claude Code's own behaviour, which is
|
|
187
|
-
// bounded and known-good. OPENZOO_UNBOUNDED_CONTEXT=1 restores the old
|
|
188
|
-
// settings for anyone who wants them back.
|
|
189
|
-
const unboundedContext = baseEnv.OPENZOO_UNBOUNDED_CONTEXT === '1';
|
|
190
|
-
if (unboundedContext && baseEnv.OPENZOO_KEEP_COMPACT !== '1') {
|
|
167
|
+
if (baseEnv.OPENZOO_KEEP_COMPACT !== '1') {
|
|
191
168
|
env.DISABLE_COMPACT = baseEnv.DISABLE_COMPACT || '1';
|
|
192
169
|
env.DISABLE_AUTO_COMPACT = baseEnv.DISABLE_AUTO_COMPACT || '1';
|
|
193
170
|
}
|
|
@@ -200,9 +177,9 @@ export function claudeZooEnv(baseEnv = process.env, { base, port } = {}) {
|
|
|
200
177
|
// sent. Disabling auto-compact WITHOUT raising this made it worse: it used to
|
|
201
178
|
// compact and carry on, and instead it just stopped.
|
|
202
179
|
//
|
|
203
|
-
//
|
|
204
|
-
//
|
|
205
|
-
if (
|
|
180
|
+
// Safe only because spillTranscript is real: OPENZOO_TAIL_MAX_CHARS bounds what
|
|
181
|
+
// leaves this machine however long the conversation gets.
|
|
182
|
+
if (baseEnv.OPENZOO_KEEP_COMPACT !== '1' && !baseEnv.CLAUDE_CODE_MAX_CONTEXT_TOKENS) {
|
|
206
183
|
env.CLAUDE_CODE_MAX_CONTEXT_TOKENS = baseEnv.OPENZOO_CLAUDE_CONTEXT_TOKENS || '1000000';
|
|
207
184
|
}
|
|
208
185
|
return env;
|
|
@@ -213,18 +190,21 @@ export function claudeZooEnv(baseEnv = process.env, { base, port } = {}) {
|
|
|
213
190
|
* DEFAULT is the desktop app; `--terminal` (or `-t`) runs the Claude Code CLI.
|
|
214
191
|
* Both get ANTHROPIC_BASE_URL so inference pays x402.
|
|
215
192
|
*/
|
|
193
|
+
|
|
194
|
+
|
|
216
195
|
export async function launchClaude(argv) {
|
|
217
|
-
//
|
|
218
|
-
// below must follow the port we actually bound.
|
|
196
|
+
// NEVER hop. :8402 is the product. startProxy steals a stale listener.
|
|
219
197
|
let base = `http://localhost:${config.port}/v1`;
|
|
220
|
-
// AUTO-START THE PROXY. One command should just work — if nothing is listening
|
|
221
|
-
//
|
|
222
|
-
// 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.
|
|
223
200
|
let up = false;
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
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
|
+
}
|
|
228
208
|
if (!up) {
|
|
229
209
|
// NEVER GO SILENT DURING STARTUP. silent:true routes the proxy's own lines
|
|
230
210
|
// to ~/.openzoo/proxy.log so payment receipts cannot corrupt Claude Code's
|
|
@@ -241,7 +221,6 @@ export async function launchClaude(argv) {
|
|
|
241
221
|
tick.unref?.();
|
|
242
222
|
const done = (msg) => { clearInterval(tick); process.stderr.write(`\r\x1b[2Kopenzoo: ${msg}\n`); };
|
|
243
223
|
try {
|
|
244
|
-
const { startProxy } = await import('./proxy.js');
|
|
245
224
|
await startProxy({ silent: true, autoTunnel: true });
|
|
246
225
|
} catch (err) {
|
|
247
226
|
// An exception here used to surface as an eternal spinner. Say what broke.
|
|
@@ -250,9 +229,6 @@ export async function launchClaude(argv) {
|
|
|
250
229
|
console.error(' try: OPENZOO_NO_TUNNEL=1 npx openzoo claude (skips the cloudflared download)');
|
|
251
230
|
process.exit(1);
|
|
252
231
|
}
|
|
253
|
-
// The proxy may have healed onto a different port (8402 busy). config.port
|
|
254
|
-
// is the one it ACTUALLY bound, so re-derive every URL from it — the old
|
|
255
|
-
// code kept polling the port it wished for and timed out on a live proxy.
|
|
256
232
|
base = `http://localhost:${config.port}/v1`;
|
|
257
233
|
// PROBE /v1/info, NOT /v1/models. `models` is PROXIED UPSTREAM, so on a
|
|
258
234
|
// network with a bad path to the gateway the local proxy is listening and
|
|
@@ -384,15 +360,7 @@ export async function launchClaude(argv) {
|
|
|
384
360
|
// side by side is the only way the markup is visible while it is happening.
|
|
385
361
|
+ 'const ac=j.actual||{};'
|
|
386
362
|
+ 'const real=(ac.calls>0)?(" \\u00b7 $"+Number(ac.upstreamUsd||0).toFixed(4)+" real"+(ac.markupX?(" ("+ac.markupX+"x)"):"")):"";'
|
|
387
|
-
|
|
388
|
-
// this machine (restored from ~/.openzoo/session.json); `wallet` is what
|
|
389
|
-
// is left to pay with. Shown unlabelled, the first was read as the
|
|
390
|
-
// second — "$13.86" on screen while the wallet held $0.18 and every call
|
|
391
|
-
// 402'd underfunded. The words `spent` and `wallet` are the whole fix.
|
|
392
|
-
+ 'const wu=Number(j.walletUsd);'
|
|
393
|
-
+ 'const wcol=(!isFinite(wu)||wu<=0)?"\\x1b[31m":(wu<0.5?"\\x1b[33m":"\\x1b[90m");'
|
|
394
|
-
+ 'const wal=isFinite(wu)?(" \\u00b7 "+wcol+"wallet $"+wu.toFixed(2)+"\\x1b[0m"):"";'
|
|
395
|
-
+ 'process.stdout.write("\\x1b[38;5;208m\\u25cf\\x1b[0m openzoo spent $"+(Number(j.spendUsd)||0).toFixed(4)+" all-time"+wal+" \\u00b7 "+(j.paidCalls||0)+" call"+((j.paidCalls||0)===1?"":"s")+save+real+spill+cr+" \\u00b7 x402")}'
|
|
363
|
+
+ 'process.stdout.write("\\x1b[38;5;208m\\u25cf\\x1b[0m openzoo $"+(Number(j.spendUsd)||0).toFixed(4)+" "+(j.paidCalls||0)+" call"+((j.paidCalls||0)===1?"":"s")+save+real+spill+cr+" \\u00b7 x402")}'
|
|
396
364
|
+ 'catch{process.stdout.write("\\x1b[38;5;208m\\u25cf\\x1b[0m openzoo \\u00b7 x402")}})\'\n');
|
|
397
365
|
fs.chmodSync(scriptPath, 0o755);
|
|
398
366
|
const settingsPath = path.join(os.homedir(), '.claude', 'settings.json');
|
|
@@ -611,17 +579,12 @@ export async function launchClaude(argv) {
|
|
|
611
579
|
}
|
|
612
580
|
|
|
613
581
|
export async function launchHarness(cmd, args) {
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
if (!r.ok) throw new Error(String(r.status));
|
|
621
|
-
} catch {
|
|
622
|
-
console.error(`openzoo: no proxy reachable at ${base}`);
|
|
623
|
-
console.error('start it first in another terminal: npx openzoo');
|
|
624
|
-
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`;
|
|
625
588
|
}
|
|
626
589
|
|
|
627
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);
|
|
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;
|
|
49
112
|
}
|
|
50
|
-
return
|
|
51
|
-
}
|
|
52
|
-
|
|
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 */ }
|
|
53
122
|
}
|
|
123
|
+
return false;
|
|
54
124
|
}
|
|
55
125
|
|
|
56
126
|
// THE SHIM IS A FACILITATOR, NOT A MIDDLEBOX.
|
|
@@ -388,33 +458,6 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
|
|
|
388
458
|
let creditInflight = null;
|
|
389
459
|
let lastPrices = {};
|
|
390
460
|
let pricesAt = 0;
|
|
391
|
-
// WALLET BALANCE, CACHED LIKE CREDIT.
|
|
392
|
-
//
|
|
393
|
-
// The HUD showed one number — cumulative spend — and a user read it as their
|
|
394
|
-
// balance: "$13.86" beside a wallet holding $0.18, then every call 402'd
|
|
395
|
-
// "underfunded" and nothing on screen explained why. Spend is a total to
|
|
396
|
-
// date; the wallet is what is left. Both, labelled, or neither is legible.
|
|
397
|
-
//
|
|
398
|
-
// Never awaited on the /v1/info path: this quotes the gateway and reads
|
|
399
|
-
// chain balances, and the statusline gives it 1s. Serve last-known, refresh
|
|
400
|
-
// behind it — the same contract refreshCredit() keeps.
|
|
401
|
-
let walletUsd = null;
|
|
402
|
-
let walletAt = 0;
|
|
403
|
-
let walletInflight = null;
|
|
404
|
-
const refreshWallet = async (force = false) => {
|
|
405
|
-
if (!force && Date.now() - walletAt < 60000 && walletUsd != null) return walletUsd;
|
|
406
|
-
if (walletInflight) return walletInflight;
|
|
407
|
-
walletInflight = (async () => {
|
|
408
|
-
try {
|
|
409
|
-
const { affordableUsd } = await import('./info.js');
|
|
410
|
-
const v = await affordableUsd();
|
|
411
|
-
if (Number.isFinite(v)) { walletUsd = v; walletAt = Date.now(); }
|
|
412
|
-
} catch { /* keep last known */ }
|
|
413
|
-
walletInflight = null;
|
|
414
|
-
return walletUsd;
|
|
415
|
-
})();
|
|
416
|
-
return walletInflight;
|
|
417
|
-
};
|
|
418
461
|
const refreshCredit = async (force = false) => {
|
|
419
462
|
if (!force && Date.now() - creditAt < 20000 && creditUsd != null) return creditUsd;
|
|
420
463
|
if (creditInflight) return creditInflight;
|
|
@@ -535,23 +578,25 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
|
|
|
535
578
|
const p0 = (req.url || '').split('?')[0];
|
|
536
579
|
if (req.method === 'GET' && (p0 === '/v1/info' || p0 === '/info')) {
|
|
537
580
|
refreshCredit();
|
|
538
|
-
refreshWallet();
|
|
539
581
|
const self = viaTunnel && tunnelGate?.publicUrl
|
|
540
582
|
? `${tunnelGate.publicUrl}/v1`
|
|
541
583
|
: `http://localhost:${config.port}/v1`;
|
|
542
|
-
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
|
+
);
|
|
543
591
|
res.end(JSON.stringify({
|
|
544
592
|
youAreTalkingTo: 'openzoo proxy',
|
|
593
|
+
version: ozVersion,
|
|
594
|
+
solana: client.address,
|
|
545
595
|
yourEndpoint: self,
|
|
546
596
|
reachedVia: viaTunnel ? 'public tunnel' : 'localhost',
|
|
547
597
|
publicTunnel: tunnelGate?.publicUrl ? `${tunnelGate.publicUrl}/v1` : null,
|
|
548
598
|
servedRequests,
|
|
549
|
-
// SPENT-TO-DATE, not a balance: restored from ~/.openzoo/session.json at
|
|
550
|
-
// startup, so it spans every session on this machine.
|
|
551
599
|
spendUsd: sessionSpent,
|
|
552
|
-
spendScope: 'all-time on this machine',
|
|
553
|
-
// WHAT IS LEFT to pay with. null while the first read is in flight.
|
|
554
|
-
walletUsd,
|
|
555
600
|
creditUsd,
|
|
556
601
|
// WHAT THE SAME CALLS WOULD HAVE COST DIRECT. Spend on its own is a
|
|
557
602
|
// bill; spend beside the counterfactual is the product.
|
|
@@ -651,43 +696,10 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
|
|
|
651
696
|
const isPaidPost = req.method === 'POST'
|
|
652
697
|
&& /\/(chat\/completions|completions|messages|responses)$/.test(rawPath);
|
|
653
698
|
let wantsStream = false;
|
|
654
|
-
// RECEIPTS ARE OPT-OUT. `disableStats: true` in the body (or the
|
|
655
|
-
// x-openzoo-disable-stats header) means this caller does not want the x402
|
|
656
|
-
// block, so we neither attach our settle proof nor pass the gateway's
|
|
657
|
-
// receipt through. The field is deliberately FORWARDED, not stripped: the
|
|
658
|
-
// gateway reads it too and drops its own half. Absent/false keeps today's
|
|
659
|
-
// behaviour, because our own spend line reads that block.
|
|
660
|
-
let statsOff = false;
|
|
661
|
-
if (isPaidPost) {
|
|
662
|
-
try {
|
|
663
|
-
const b = JSON.parse(bodyBuf.toString('utf8'));
|
|
664
|
-
const h = req.headers['x-openzoo-disable-stats'];
|
|
665
|
-
const truthy = (v) => v === true || v === 'true' || v === '1' || v === 1;
|
|
666
|
-
statsOff = truthy(b?.disableStats) || truthy(Array.isArray(h) ? h[0] : h);
|
|
667
|
-
} catch { /* not JSON */ }
|
|
668
|
-
}
|
|
669
699
|
if (isPaidPost) {
|
|
670
700
|
servedRequests += 1;
|
|
671
701
|
say(`\n<- request #${servedRequests} from ${(req.headers['user-agent'] || 'unknown').slice(0, 40)}`);
|
|
672
702
|
try { wantsStream = JSON.parse(bodyBuf.toString('utf8'))?.stream === true; } catch { /* not JSON */ }
|
|
673
|
-
// UNOPENROUTER THE MODEL ID before anything downstream sees the body —
|
|
674
|
-
// the replay key, the outage gate, the wire. A vendor-prefixed
|
|
675
|
-
// OpenRouter spelling becomes the bare id the doors serve when the
|
|
676
|
-
// catalog lists it (OPENZOO_UNOPENROUTER=1 forces it, =0 disables).
|
|
677
|
-
// This is the one place the body is rewritten; see models.js.
|
|
678
|
-
try {
|
|
679
|
-
const parsed = JSON.parse(bodyBuf.toString('utf8'));
|
|
680
|
-
if (parsed && typeof parsed.model === 'string') {
|
|
681
|
-
let ids = [];
|
|
682
|
-
try { ids = await catalogIdsCached(`${config.apiBase}/v1/models`, upstreamHeaders(req)); } catch { /* catalog unreachable: only the forced mode rewrites */ }
|
|
683
|
-
const bare = unopenrouter(parsed.model, ids);
|
|
684
|
-
if (bare) {
|
|
685
|
-
say(` model ${parsed.model} -> ${bare} (bare id: doors, not OpenRouter)`);
|
|
686
|
-
parsed.model = bare;
|
|
687
|
-
bodyBuf = Buffer.from(JSON.stringify(parsed));
|
|
688
|
-
}
|
|
689
|
-
}
|
|
690
|
-
} catch { /* not JSON */ }
|
|
691
703
|
}
|
|
692
704
|
|
|
693
705
|
// Retry of a body we answered seconds ago? Serve the cached completion —
|
|
@@ -858,11 +870,7 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
|
|
|
858
870
|
say(`credit -> $${x.billedUsd.toFixed(6)} · session $${sessionSpent.toFixed(6)}`);
|
|
859
871
|
}
|
|
860
872
|
if (data?.object === 'chat.completion') {
|
|
861
|
-
if (
|
|
862
|
-
// The gateway already dropped its half; drop ours, and any block an
|
|
863
|
-
// older gateway in front of us still attached.
|
|
864
|
-
delete data.x402;
|
|
865
|
-
} else if (paid && receipt) {
|
|
873
|
+
if (paid && receipt) {
|
|
866
874
|
attachX402Proof(data, {
|
|
867
875
|
tx: receipt.tx,
|
|
868
876
|
memo: receipt.memo || accept?.extra?.memo,
|
|
@@ -899,14 +907,13 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
|
|
|
899
907
|
rememberSpend();
|
|
900
908
|
say(`credit -> $${x.billedUsd.toFixed(6)} · session $${sessionSpent.toFixed(6)}`);
|
|
901
909
|
};
|
|
902
|
-
// A 402 AFTER we attempted payment is a SETTLEMENT failure, not a quote
|
|
903
|
-
//
|
|
904
|
-
//
|
|
905
|
-
// retry with a fresh 402. Relaying that
|
|
906
|
-
//
|
|
907
|
-
//
|
|
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.
|
|
908
916
|
if (response.status === 402) {
|
|
909
|
-
let quoted = '';
|
|
910
917
|
let usd;
|
|
911
918
|
let q402 = null;
|
|
912
919
|
try { q402 = await response.clone().json(); } catch { q402 = null; }
|
|
@@ -923,18 +930,21 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
|
|
|
923
930
|
return;
|
|
924
931
|
}
|
|
925
932
|
try {
|
|
926
|
-
|
|
927
|
-
usd =
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
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);
|
|
938
948
|
return;
|
|
939
949
|
}
|
|
940
950
|
await relay(res, response, meterStreamed);
|
|
@@ -967,11 +977,18 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
|
|
|
967
977
|
// OPENZOO_BIND=0.0.0.0 AND a tunnel token, so that port stays gated exactly
|
|
968
978
|
// like the public tunnel path.
|
|
969
979
|
const bindHost = process.env.OPENZOO_BIND || '127.0.0.1';
|
|
970
|
-
// SELF-HEAL
|
|
971
|
-
//
|
|
972
|
-
//
|
|
973
|
-
//
|
|
974
|
-
|
|
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
|
+
}
|
|
975
992
|
for (let attempt = 0; ; attempt++) {
|
|
976
993
|
try {
|
|
977
994
|
await new Promise((resolve, reject) => {
|
|
@@ -981,20 +998,12 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
|
|
|
981
998
|
});
|
|
982
999
|
break;
|
|
983
1000
|
} catch (e) {
|
|
984
|
-
if (e?.code !== 'EADDRINUSE' || attempt >=
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
say(`openzoo: killed proxy on :${config.port} (pids ${pids.join(',')})`);
|
|
989
|
-
await new Promise((r) => setTimeout(r, 400));
|
|
990
|
-
continue;
|
|
991
|
-
}
|
|
992
|
-
}
|
|
993
|
-
config.port += 1;
|
|
994
|
-
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));
|
|
995
1005
|
}
|
|
996
1006
|
}
|
|
997
|
-
if (config.port !== wanted) say(`openzoo: listening on :${config.port} (:${wanted} was busy)`);
|
|
998
1007
|
|
|
999
1008
|
// AUTO-PREPAY. Paying on-chain per call is where the latency lives: credit
|
|
1000
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
|
-
}
|