openzoo 0.51.8 → 0.51.10
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/launcher/README.md +7 -0
- package/launcher/main.cjs +34 -0
- package/launcher/package.json +56 -0
- package/launcher/runtime.cjs +59 -0
- package/launcher/savings-preload.cjs +5 -0
- package/launcher/savings-renderer.js +9 -0
- package/launcher/savings.cjs +41 -0
- package/launcher/savings.html +3 -0
- package/lib/billing.js +30 -0
- package/lib/chatgpt-spend.js +33 -13
- package/lib/proxy.js +37 -51
- package/lib/x402.js +4 -31
- package/package.json +9 -2
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# OpenZoo Launcher
|
|
2
|
+
|
|
3
|
+
One installer for ChatGPT. Setup starts automatically when you open the launcher, with a floating savings window showing wallet balance, spend, and savings. Node and npm are bundled; no global runtime or terminal is required. The launcher checks npm's stable `openzoo` release on each launch, installs newer versions into its per-user application data directory, and falls back to the bundled or last successfully installed CLI when offline. It never downgrades. The selected CLI downloads the current chat app if missing and preserves existing installations.
|
|
4
|
+
|
|
5
|
+
Build natively with `npm ci`, `npm run prepare:bundle`, then `npm run dist`. Preparation resolves current Node LTS from nodejs.org, checks the archive SHA-256, and bundles this checkout's OpenZoo package. Push a `launcher-v1.0.0` style tag to publish six native installers through Actions; manual dispatch builds downloadable workflow artifacts without publishing a release. macOS uses existing Apple signing/notarization secrets. Windows currently ships unsigned.
|
|
6
|
+
|
|
7
|
+
Downloaded CLI updates execute as the current user, just like installing OpenZoo from npm. Updates are staged and promoted after a CLI smoke check. Launcher/Electron updates require downloading a newer installer. Configuration, wallets, downloaded apps and background proxy processes belong to OpenZoo and survive closing/uninstalling the launcher.
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
const { app, BrowserWindow, ipcMain } = require('electron');
|
|
2
|
+
const path = require('node:path');
|
|
3
|
+
const { resolveCLI, run } = require('./runtime.cjs');
|
|
4
|
+
const { showSavings } = require('./savings.cjs');
|
|
5
|
+
let window, busy = false;
|
|
6
|
+
if (!app.requestSingleInstanceLock({ savingsReady: process.env.OPENZOO_SAVINGS_READY })) app.quit();
|
|
7
|
+
else {
|
|
8
|
+
app.on('second-instance', (_event, argv, _cwd, data) => { if (argv.includes('--savings-only')) {
|
|
9
|
+
const pill = showSavings();
|
|
10
|
+
const ready = () => { if (data?.savingsReady) { try { require('node:fs').writeFileSync(data.savingsReady, 'ready'); } catch {} } };
|
|
11
|
+
if (pill.webContents.isLoading()) pill.webContents.once('did-finish-load', ready); else ready();
|
|
12
|
+
} else { window?.show(); window?.focus(); } });
|
|
13
|
+
app.whenReady().then(() => {
|
|
14
|
+
if (process.argv.includes('--savings-only')) { showSavings(); return; }
|
|
15
|
+
window = new BrowserWindow({ width: 740, height: 660, backgroundColor: '#101110', webPreferences: { preload: path.join(__dirname, 'preload.cjs'), contextIsolation: true, nodeIntegration: false, sandbox: true } });
|
|
16
|
+
window.webContents.setWindowOpenHandler(() => ({ action: 'deny' }));
|
|
17
|
+
window.webContents.on('will-navigate', event => event.preventDefault());
|
|
18
|
+
window.loadFile(path.join(__dirname, 'index.html'));
|
|
19
|
+
ipcMain.handle('launch', async (event, command) => {
|
|
20
|
+
if (event.sender !== window.webContents || command !== 'chatgpt' || busy) throw new Error('Launch unavailable');
|
|
21
|
+
busy = true;
|
|
22
|
+
const log = message => { if (!window.isDestroyed()) window.webContents.send('log', message); };
|
|
23
|
+
try {
|
|
24
|
+
const bundle = app.isPackaged ? path.join(process.resourcesPath, 'bundle') : path.join(__dirname, 'bundle');
|
|
25
|
+
const { node, cli } = await resolveCLI(bundle, app.getPath('userData'), log);
|
|
26
|
+
showSavings();
|
|
27
|
+
await run(node, [cli, 'chatgpt', '--no-pill'], log);
|
|
28
|
+
log('\nLaunched. You can close this window.\n');
|
|
29
|
+
} catch (e) { log(`\n${e.message}\n`); throw e; }
|
|
30
|
+
finally { busy = false; }
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
app.on('window-all-closed', () => app.quit());
|
|
34
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "openzoo-launcher",
|
|
3
|
+
"version": "1.1.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"main": "main.cjs",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"prepare:bundle": "node prepare.mjs",
|
|
8
|
+
"dist": "electron-builder",
|
|
9
|
+
"test": "node --test test.cjs"
|
|
10
|
+
},
|
|
11
|
+
"devDependencies": {
|
|
12
|
+
"electron": "44.3.0",
|
|
13
|
+
"electron-builder": "26.15.3"
|
|
14
|
+
},
|
|
15
|
+
"build": {
|
|
16
|
+
"appId": "fun.openzoo.launcher",
|
|
17
|
+
"productName": "OpenZoo Launcher",
|
|
18
|
+
"files": [
|
|
19
|
+
"main.cjs",
|
|
20
|
+
"runtime.cjs",
|
|
21
|
+
"preload.cjs",
|
|
22
|
+
"index.html",
|
|
23
|
+
"savings.cjs",
|
|
24
|
+
"savings-preload.cjs",
|
|
25
|
+
"savings.html",
|
|
26
|
+
"savings-renderer.js"
|
|
27
|
+
],
|
|
28
|
+
"extraResources": [
|
|
29
|
+
{
|
|
30
|
+
"from": "bundle",
|
|
31
|
+
"to": "bundle"
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
"from": "openzoo-overlay.json",
|
|
35
|
+
"to": "openzoo-overlay.json"
|
|
36
|
+
}
|
|
37
|
+
],
|
|
38
|
+
"artifactName": "OpenZoo-Launcher-${version}-${os}-${arch}.${ext}",
|
|
39
|
+
"mac": {
|
|
40
|
+
"target": "dmg",
|
|
41
|
+
"hardenedRuntime": true
|
|
42
|
+
},
|
|
43
|
+
"win": {
|
|
44
|
+
"target": "nsis"
|
|
45
|
+
},
|
|
46
|
+
"nsis": {
|
|
47
|
+
"oneClick": false,
|
|
48
|
+
"perMachine": false,
|
|
49
|
+
"allowToChangeInstallationDirectory": true
|
|
50
|
+
},
|
|
51
|
+
"linux": {
|
|
52
|
+
"target": "AppImage",
|
|
53
|
+
"category": "Network"
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
const path = require('node:path');
|
|
2
|
+
const fs = require('node:fs/promises');
|
|
3
|
+
const { spawn } = require('node:child_process');
|
|
4
|
+
const stable = v => /^\d+\.\d+\.\d+$/.test(v);
|
|
5
|
+
const newer = (a, b) => {
|
|
6
|
+
if (!stable(a) || !stable(b)) return false;
|
|
7
|
+
const aa = a.split('.').map(Number), bb = b.split('.').map(Number);
|
|
8
|
+
for (let i = 0; i < 3; i++) if (aa[i] !== bb[i]) return aa[i] > bb[i];
|
|
9
|
+
return false;
|
|
10
|
+
};
|
|
11
|
+
function environment(node) {
|
|
12
|
+
const env = { ...process.env, PATH: `${path.dirname(node)}${path.delimiter}${process.env.PATH || ''}` };
|
|
13
|
+
delete env.ELECTRON_RUN_AS_NODE;
|
|
14
|
+
delete env.NODE_OPTIONS;
|
|
15
|
+
return env;
|
|
16
|
+
}
|
|
17
|
+
function run(node, args, log, timeout = 600000) {
|
|
18
|
+
return new Promise((resolve, reject) => {
|
|
19
|
+
const child = spawn(node, args, { env: environment(node), windowsHide: true });
|
|
20
|
+
const timer = setTimeout(() => { child.kill(); reject(new Error('Operation timed out. Please try again.')); }, timeout);
|
|
21
|
+
child.stdout.on('data', b => log(b.toString()));
|
|
22
|
+
child.stderr.on('data', b => log(b.toString()));
|
|
23
|
+
child.on('error', e => { clearTimeout(timer); reject(e); });
|
|
24
|
+
child.on('exit', code => { clearTimeout(timer); code === 0 ? resolve() : reject(new Error(`OpenZoo exited with code ${code}`)); });
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
async function resolveCLI(bundle, userData, log) {
|
|
28
|
+
const node = path.join(bundle, 'runtime', process.platform === 'win32' ? 'node.exe' : 'bin/node');
|
|
29
|
+
const npm = path.join(bundle, 'runtime', process.platform === 'win32' ? 'node_modules/npm/bin/npm-cli.js' : 'lib/node_modules/npm/bin/npm-cli.js');
|
|
30
|
+
const pkg = dir => path.join(dir, 'node_modules/openzoo');
|
|
31
|
+
let selected = path.join(bundle, 'sidecar');
|
|
32
|
+
let version = JSON.parse(await fs.readFile(path.join(pkg(selected), 'package.json'), 'utf8')).version;
|
|
33
|
+
const updates = path.join(userData, 'versions');
|
|
34
|
+
await fs.mkdir(updates, { recursive: true });
|
|
35
|
+
for (const v of await fs.readdir(updates)) {
|
|
36
|
+
if (newer(v, version)) {
|
|
37
|
+
try { if (JSON.parse(await fs.readFile(path.join(pkg(path.join(updates, v)), 'package.json'), 'utf8')).version === v) { selected = path.join(updates, v); version = v; } } catch {}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
try {
|
|
41
|
+
log('Checking for the latest OpenZoo…\n');
|
|
42
|
+
const response = await fetch('https://registry.npmjs.org/openzoo/latest', { signal: AbortSignal.timeout(10000) });
|
|
43
|
+
if (!response.ok) throw new Error(`Registry returned ${response.status}`);
|
|
44
|
+
const latest = (await response.json()).version;
|
|
45
|
+
if (newer(latest, version)) {
|
|
46
|
+
const stage = await fs.mkdtemp(path.join(updates, '.install-'));
|
|
47
|
+
try {
|
|
48
|
+
await run(node, [npm, 'install', '--prefix', stage, '--registry=https://registry.npmjs.org', '--omit=dev', '--no-audit', '--no-fund', `openzoo@${latest}`], log);
|
|
49
|
+
await run(node, [path.join(stage, 'node_modules/dugite/script/download-git.js')], log);
|
|
50
|
+
await run(node, [path.join(pkg(stage), 'bin/openzoo.js'), '--help'], () => {}, 30000);
|
|
51
|
+
await fs.rename(stage, path.join(updates, latest));
|
|
52
|
+
selected = path.join(updates, latest); version = latest;
|
|
53
|
+
} finally { await fs.rm(stage, { recursive: true, force: true }); }
|
|
54
|
+
}
|
|
55
|
+
} catch (e) { log(`Update unavailable (${e.message}). Using installed OpenZoo ${version}.\n`); }
|
|
56
|
+
log(`Starting OpenZoo ${version}…\n`);
|
|
57
|
+
return { node, cli: path.join(pkg(selected), 'bin/openzoo.js') };
|
|
58
|
+
}
|
|
59
|
+
module.exports = { newer, environment, resolveCLI, run };
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
const usd = n => typeof n === 'number' && Number.isFinite(n) ? '$' + Math.abs(n).toFixed(Math.abs(n) < .01 && n !== 0 ? 4 : 2) : '—';
|
|
2
|
+
window.savings.listen(d => {
|
|
3
|
+
for (const [id, value] of [['wallet',d.walletUsd],['credit',d.creditUsd],['spent',d.billing?.calls > 0 ? d.billing.chargedUsd : null],['direct',d.comparison?.calls > 0 ? d.comparison.directUsd : null]]) document.getElementById(id).textContent=usd(value);
|
|
4
|
+
const saved=d.comparison?.calls > 0 ? d.comparison.directUsd-d.comparison.spentUsd : null;
|
|
5
|
+
document.getElementById('saved').textContent=usd(saved);
|
|
6
|
+
document.getElementById('saving-label').textContent=saved!==null && saved<0 ? 'Extra cost' : 'Saved';
|
|
7
|
+
document.getElementById('state').textContent=d.online ? (d.billing?.calls > 0 ? 'Final charges · since connection started' : 'Waiting for final billing · balance is live') : 'Reconnecting · showing last known amounts';
|
|
8
|
+
});
|
|
9
|
+
document.getElementById('close').addEventListener('click',()=>window.savings.close());
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
const { BrowserWindow, screen, ipcMain } = require('electron');
|
|
2
|
+
const path = require('node:path');
|
|
3
|
+
let pill;
|
|
4
|
+
function showSavings(port = Number(process.env.OPENZOO_SAVINGS_PORT) || 8402) {
|
|
5
|
+
if (pill && !pill.isDestroyed()) { pill.show(); return pill; }
|
|
6
|
+
const { x, y, width, height } = screen.getPrimaryDisplay().workArea;
|
|
7
|
+
pill = new BrowserWindow({ width: 420, height: 270, x: x + width - 440, y: y + height - 290,
|
|
8
|
+
frame: false, transparent: true, alwaysOnTop: true, skipTaskbar: true, resizable: false,
|
|
9
|
+
webPreferences: { preload: path.join(__dirname, 'savings-preload.cjs'), contextIsolation: true, sandbox: true, nodeIntegration: false } });
|
|
10
|
+
pill.setAlwaysOnTop(true, 'floating');
|
|
11
|
+
pill.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true });
|
|
12
|
+
pill.webContents.setWindowOpenHandler(() => ({ action: 'deny' }));
|
|
13
|
+
pill.webContents.on('will-navigate', e => e.preventDefault());
|
|
14
|
+
pill.loadFile(path.join(__dirname, 'savings.html'));
|
|
15
|
+
const window = pill;
|
|
16
|
+
let busy = false, last = null;
|
|
17
|
+
const poll = async () => {
|
|
18
|
+
if (busy || window.isDestroyed()) return;
|
|
19
|
+
busy = true;
|
|
20
|
+
try {
|
|
21
|
+
const r = await fetch(`http://127.0.0.1:${port}/v1/info`, { signal: AbortSignal.timeout(2500) });
|
|
22
|
+
const d = await r.json();
|
|
23
|
+
if (!r.ok || d.youAreTalkingTo !== 'openzoo proxy') throw Error('Unavailable');
|
|
24
|
+
last = d;
|
|
25
|
+
if (!window.isDestroyed()) window.webContents.send('savings', { ...d, online: true });
|
|
26
|
+
} catch { if (!window.isDestroyed()) window.webContents.send('savings', { ...last, online: false }); }
|
|
27
|
+
finally { busy = false; }
|
|
28
|
+
};
|
|
29
|
+
window.webContents.on('did-finish-load', () => {
|
|
30
|
+
poll();
|
|
31
|
+
if (process.env.OPENZOO_SAVINGS_READY) {
|
|
32
|
+
try { require('node:fs').writeFileSync(process.env.OPENZOO_SAVINGS_READY, 'ready'); } catch {}
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
const timer = setInterval(poll, 3000);
|
|
36
|
+
const close = event => { if (event.sender === window.webContents) window.close(); };
|
|
37
|
+
ipcMain.on('close-savings', close);
|
|
38
|
+
window.on('closed', () => { clearInterval(timer); ipcMain.removeListener('close-savings', close); if (pill === window) pill = null; });
|
|
39
|
+
return pill;
|
|
40
|
+
}
|
|
41
|
+
module.exports = { showSavings };
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
<!doctype html><html><head><meta charset="utf-8"><meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; script-src 'self'"><title>OpenZoo savings</title><style>
|
|
2
|
+
*{box-sizing:border-box}body{margin:0;padding:8px;background:transparent;font:13px system-ui;color:#fff}.pill{border-radius:20px;background:#18181bf5;border:1px solid #ffffff25;overflow:hidden;box-shadow:0 4px 14px #0004}header{background:#e85d1c;padding:12px 16px;font-weight:650;-webkit-app-region:drag;display:flex;justify-content:space-between;align-items:center}button{-webkit-app-region:no-drag;border:0;background:none;color:inherit;font:18px system-ui;cursor:pointer;padding:0 5px}main{padding:12px 16px}dl{display:grid;grid-template-columns:1fr auto;margin:0;gap:7px}dd{margin:0;font-variant-numeric:tabular-nums}small{color:#bbb;display:block;margin-top:12px;font-size:11px}#state{color:#d8ff76}
|
|
3
|
+
</style></head><body><div class="pill"><header><span>OpenZoo · costs & savings</span><button id="close" aria-label="Close savings pill">×</button></header><main><dl><dt>Wallet balance</dt><dd id="wallet">—</dd><dt>Prepaid credit</dt><dd id="credit">—</dd><dt>Charged · verified calls</dt><dd id="spent">—</dd><dt>Direct cost · verified calls</dt><dd id="direct">—</dd><dt id="saving-label">Saved</dt><dd id="saved">—</dd></dl><small id="state" role="status">Checking your balance…</small><small>Savings cover matching verified calls only. Drag the orange bar to move.</small></main></div><script src="savings-renderer.js"></script></body></html>
|
package/lib/billing.js
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
const money = n => typeof n === 'number' && Number.isFinite(n) && n >= 0 ? n : null;
|
|
2
|
+
|
|
3
|
+
// Only final, explicitly reported charges. Never substitute upstream cost or a quote.
|
|
4
|
+
export function finalBilling(x = {}, usage = {}) {
|
|
5
|
+
const charged = [usage.billedUsd, x.billedActualUsd, x.settledBilledUsd, x.chargedUsd,
|
|
6
|
+
x.settledUsd, usage.cost_details?.upstream_inference_cost != null ? usage.cost : null].map(money).find(n => n !== null) ?? null;
|
|
7
|
+
const direct = x.pricing === 'counterfactual' ? money(x.directUsd) : null;
|
|
8
|
+
return { charged, direct, upstream: money(usage.cost_details?.upstream_inference_cost) };
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// Observe billing without retaining prompts, output text, or changing the stream.
|
|
12
|
+
export function billingObserver(onReceipt) {
|
|
13
|
+
let pending = '';
|
|
14
|
+
return chunk => {
|
|
15
|
+
pending += chunk;
|
|
16
|
+
let i;
|
|
17
|
+
while ((i = pending.indexOf('\n')) >= 0) {
|
|
18
|
+
const line = pending.slice(0, i).trimEnd(); pending = pending.slice(i + 1);
|
|
19
|
+
try {
|
|
20
|
+
if (line.startsWith(': x402 ')) onReceipt(JSON.parse(line.slice(7)));
|
|
21
|
+
else if (line.startsWith('data:')) {
|
|
22
|
+
const event = JSON.parse(line.slice(5).trim());
|
|
23
|
+
const value = event.response || event;
|
|
24
|
+
if (value.usage || value.x402) onReceipt({ ...value.x402, usage: value.usage });
|
|
25
|
+
}
|
|
26
|
+
} catch { /* Not a billing event. */ }
|
|
27
|
+
}
|
|
28
|
+
if (pending.length > 2_000_000) pending = '';
|
|
29
|
+
};
|
|
30
|
+
}
|
package/lib/chatgpt-spend.js
CHANGED
|
@@ -163,21 +163,41 @@ export function nativeSavingsLauncher({ platform = process.platform, home = os.h
|
|
|
163
163
|
return null;
|
|
164
164
|
}
|
|
165
165
|
|
|
166
|
-
export async function startChatGptSpend({ profile, proxyPort, spawnImpl = spawn } = {}) {
|
|
167
|
-
const
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
166
|
+
export async function startChatGptSpend({ profile, proxyPort = 8402, spawnImpl = spawn } = {}) {
|
|
167
|
+
const app = fileURLToPath(new URL('../launcher/', import.meta.url));
|
|
168
|
+
const electron = path.join(app, 'node_modules/electron/dist', process.platform === 'darwin'
|
|
169
|
+
? 'Electron.app/Contents/MacOS/Electron' : process.platform === 'win32' ? 'electron.exe' : 'electron');
|
|
170
|
+
const env = { ...process.env, OPENZOO_SAVINGS_PORT: String(proxyPort) };
|
|
171
|
+
delete env.ELECTRON_RUN_AS_NODE;
|
|
172
|
+
const readyDir = fs.mkdtempSync(path.join(os.tmpdir(), 'openzoo-pill-'));
|
|
173
|
+
env.OPENZOO_SAVINGS_READY = path.join(readyDir, 'ready');
|
|
174
|
+
const logDir = profile || path.join(os.homedir(), '.openzoo');
|
|
175
|
+
fs.mkdirSync(logDir, { recursive: true });
|
|
176
|
+
const logPath = path.join(logDir, 'openzoo-savings.log');
|
|
177
|
+
const fd = fs.openSync(logPath, 'a', 0o600);
|
|
178
|
+
let child, failure;
|
|
176
179
|
try {
|
|
177
|
-
|
|
178
|
-
|
|
180
|
+
if (fs.existsSync(electron)) {
|
|
181
|
+
child = spawnImpl(electron, [app, '--savings-only'], { detached: true, stdio: ['ignore', fd, fd], env });
|
|
182
|
+
} else {
|
|
183
|
+
// npm caches the standalone display runtime. The CLI ships the display itself.
|
|
184
|
+
// No ChatGPT debugging port or separate OpenZoo installer is required.
|
|
185
|
+
const args = ['exec', '--yes', '--package=electron@44.3.0', '--', 'electron', app, '--savings-only'];
|
|
186
|
+
const npmScript = process.env.npm_execpath;
|
|
187
|
+
child = npmScript && fs.existsSync(npmScript)
|
|
188
|
+
? spawnImpl(process.execPath, [npmScript, ...args], { detached: true, stdio: ['ignore', fd, fd], env })
|
|
189
|
+
: spawnImpl(process.platform === 'win32' ? 'npm.cmd' : 'npm', args, { detached: true, stdio: ['ignore', fd, fd], env, shell: process.platform === 'win32' });
|
|
190
|
+
}
|
|
191
|
+
child.once('error', error => { failure = error; });
|
|
192
|
+
child.once('exit', code => { if (code) failure = new Error(`Display exited (${code})`); });
|
|
179
193
|
child.unref();
|
|
180
|
-
|
|
194
|
+
const deadline = Date.now() + 120000;
|
|
195
|
+
while (!fs.existsSync(env.OPENZOO_SAVINGS_READY)) {
|
|
196
|
+
if (failure) throw failure;
|
|
197
|
+
if (Date.now() > deadline) throw new Error(`Display did not open. See ${logPath}`);
|
|
198
|
+
await new Promise(resolve => setTimeout(resolve, 200));
|
|
199
|
+
}
|
|
200
|
+
} finally { fs.closeSync(fd); fs.rmSync(readyDir, { recursive: true, force: true }); }
|
|
181
201
|
}
|
|
182
202
|
|
|
183
203
|
/** One watcher per profile, including concurrent launcher invocations. */
|
package/lib/proxy.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { billingObserver, finalBilling } from './billing.js';
|
|
1
2
|
import { liveBalance } from './funding-status.js';
|
|
2
3
|
import { paymentError } from './payment-error.js';
|
|
3
4
|
import { readFileSync, appendFileSync, mkdirSync } from 'node:fs';
|
|
@@ -234,16 +235,10 @@ function relay(res, upstream, onReceipt) {
|
|
|
234
235
|
res.on('close', () => body.destroy());
|
|
235
236
|
body.on('end', resolve);
|
|
236
237
|
if (!sse || typeof onReceipt !== 'function') { body.pipe(res); return; }
|
|
237
|
-
|
|
238
|
+
const observe = billingObserver(onReceipt);
|
|
238
239
|
body.on('data', (c) => {
|
|
239
240
|
res.write(c);
|
|
240
|
-
|
|
241
|
-
const lines = pending.split('\n');
|
|
242
|
-
pending = lines.pop() ?? ''; // a comment can straddle two chunks
|
|
243
|
-
for (const line of lines) {
|
|
244
|
-
if (!line.startsWith(': x402 ')) continue;
|
|
245
|
-
try { onReceipt(JSON.parse(line.slice(7))); } catch { /* not our frame */ }
|
|
246
|
-
}
|
|
241
|
+
observe(c.toString('utf8'));
|
|
247
242
|
});
|
|
248
243
|
body.on('end', () => res.end());
|
|
249
244
|
});
|
|
@@ -429,6 +424,8 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
|
|
|
429
424
|
let lastQuoteUsd = null;
|
|
430
425
|
let sessionCogs = restored.cogsUsd;
|
|
431
426
|
let sessionDirect = restored.directUsd;
|
|
427
|
+
let comparedSpent = 0, comparedDirect = 0, comparedCalls = 0;
|
|
428
|
+
let verifiedCharged = 0, verifiedCalls = 0;
|
|
432
429
|
const rememberSpend = () => {
|
|
433
430
|
saveSessionSpend({ spentUsd: sessionSpent, cogsUsd: sessionCogs, directUsd: sessionDirect, paidCalls });
|
|
434
431
|
};
|
|
@@ -659,6 +656,8 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
|
|
|
659
656
|
creditUsd,
|
|
660
657
|
// WHAT THE SAME CALLS WOULD HAVE COST DIRECT. Spend on its own is a
|
|
661
658
|
// bill; spend beside the counterfactual is the product.
|
|
659
|
+
billing: { chargedUsd: verifiedCharged, calls: verifiedCalls, scope: "verified calls since proxy startup" },
|
|
660
|
+
comparison: { spentUsd: comparedSpent, directUsd: comparedDirect, calls: comparedCalls },
|
|
662
661
|
directUsd: sessionDirect,
|
|
663
662
|
savedUsd: Math.max(0, sessionDirect - sessionSpent),
|
|
664
663
|
savingX: sessionSpent > 0 ? Number((sessionDirect / sessionSpent).toFixed(4)) : null,
|
|
@@ -965,36 +964,39 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
|
|
|
965
964
|
}
|
|
966
965
|
scheduleRefresh(4000); // settlement lands on-chain in a few seconds
|
|
967
966
|
}
|
|
967
|
+
let accounted = paid && receipt?.ok && typeof receipt.billedUsd === 'number' ? receipt.billedUsd : 0;
|
|
968
|
+
let counted = paid && receipt?.ok && typeof receipt.billedUsd === 'number';
|
|
969
|
+
let finalX = {}, finalUsage = {}, comparison = null, actualPair = null, verified = null;
|
|
970
|
+
const meterFinal = (x = {}) => {
|
|
971
|
+
const { usage, ...fields } = x;
|
|
972
|
+
finalX = { ...finalX, ...fields };
|
|
973
|
+
finalUsage = { ...finalUsage, ...usage };
|
|
974
|
+
const final = finalBilling(finalX, finalUsage);
|
|
975
|
+
if (final.charged === null) return;
|
|
976
|
+
if (verified === null) verifiedCalls++;
|
|
977
|
+
verifiedCharged += final.charged - (verified ?? 0);
|
|
978
|
+
verified = final.charged;
|
|
979
|
+
const delta = final.charged - accounted;
|
|
980
|
+
sessionSpent += delta;
|
|
981
|
+
if (viaTunnel) tunnelSpent += delta;
|
|
982
|
+
if (!counted) { paidCalls++; counted = true; }
|
|
983
|
+
accounted = final.charged;
|
|
984
|
+
if (comparison) { comparedSpent -= comparison.charged; comparedDirect -= comparison.direct; comparedCalls--; comparison = null; }
|
|
985
|
+
if (final.direct !== null) {
|
|
986
|
+
comparison = final;
|
|
987
|
+
comparedSpent += final.charged; comparedDirect += final.direct; comparedCalls++;
|
|
988
|
+
}
|
|
989
|
+
if (actualPair) { sessionActual -= actualPair.upstream; billedWithActual -= actualPair.charged; actualCalls--; }
|
|
990
|
+
actualPair = final.upstream === null ? null : final;
|
|
991
|
+
if (actualPair) { sessionActual += final.upstream; billedWithActual += final.charged; actualCalls++; }
|
|
992
|
+
rememberSpend();
|
|
993
|
+
log(`final billing: charged $${final.charged.toFixed(6)} · upstream ${final.upstream === null ? 'unreported' : '$' + final.upstream.toFixed(6)} · direct ${final.direct === null ? 'unreported' : '$' + final.direct.toFixed(6)}`);
|
|
994
|
+
};
|
|
968
995
|
const upCt = response.headers.get('content-type') || '';
|
|
969
996
|
if (isPaidPost && response.ok && upCt.includes('application/json')) {
|
|
970
997
|
let data = null;
|
|
971
998
|
try { data = await response.clone().json(); } catch { /* not JSON after all */ }
|
|
972
|
-
|
|
973
|
-
// completion already; no extra call.
|
|
974
|
-
{
|
|
975
|
-
// PAIR THE NUMERATOR WITH THE DENOMINATOR: usage.cost with the
|
|
976
|
-
// post-completion billed twin, never the quote reserve.
|
|
977
|
-
const pair = pairActualBilled(data?.x402, data?.usage);
|
|
978
|
-
if (pair) {
|
|
979
|
-
sessionActual += pair.upstreamUsd;
|
|
980
|
-
actualCalls += 1;
|
|
981
|
-
billedWithActual += pair.billedUsd;
|
|
982
|
-
}
|
|
983
|
-
}
|
|
984
|
-
// PREPAID CALLS STILL COST MONEY. When prepaid credit covers the quote
|
|
985
|
-
// the gateway serves 200 on the FIRST request — no 402, no payment, no
|
|
986
|
-
// receipt. The receipt still rides the response body, so meter it there.
|
|
987
|
-
if (!paid && data?.x402 && typeof data.x402.billedUsd === 'number') {
|
|
988
|
-
const x = data.x402;
|
|
989
|
-
sessionSpent += x.billedUsd;
|
|
990
|
-
sessionCogs += receiptUsedCogs(x);
|
|
991
|
-
noteQuote(x);
|
|
992
|
-
sessionDirect += receiptDirectUsd(x);
|
|
993
|
-
paidCalls += 1;
|
|
994
|
-
if (viaTunnel) tunnelSpent += x.billedUsd;
|
|
995
|
-
rememberSpend();
|
|
996
|
-
say(`credit -> $${x.billedUsd.toFixed(6)} · session $${sessionSpent.toFixed(6)}`);
|
|
997
|
-
}
|
|
999
|
+
meterFinal({ ...data?.x402, usage: data?.usage });
|
|
998
1000
|
if (data?.object === 'chat.completion') {
|
|
999
1001
|
if (statsOff) {
|
|
1000
1002
|
// The gateway already dropped its half; drop ours, and any block an
|
|
@@ -1020,23 +1022,7 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
|
|
|
1020
1022
|
// A STREAMED call is metered from the gateway's trailing SSE comment —
|
|
1021
1023
|
// same figures the JSON path reads out of `data.x402`, same counters, so
|
|
1022
1024
|
// the status line does not care which transport served the answer.
|
|
1023
|
-
const meterStreamed =
|
|
1024
|
-
const pair = pairActualBilled(x, x?.usage);
|
|
1025
|
-
if (pair) {
|
|
1026
|
-
sessionActual += pair.upstreamUsd;
|
|
1027
|
-
actualCalls += 1;
|
|
1028
|
-
billedWithActual += pair.billedUsd;
|
|
1029
|
-
}
|
|
1030
|
-
if (paid || typeof x?.billedUsd !== 'number') return;
|
|
1031
|
-
sessionSpent += x.billedUsd;
|
|
1032
|
-
sessionCogs += receiptUsedCogs(x);
|
|
1033
|
-
noteQuote(x);
|
|
1034
|
-
sessionDirect += receiptDirectUsd(x);
|
|
1035
|
-
paidCalls += 1;
|
|
1036
|
-
if (viaTunnel) tunnelSpent += x.billedUsd;
|
|
1037
|
-
rememberSpend();
|
|
1038
|
-
say(`credit -> $${x.billedUsd.toFixed(6)} · session $${sessionSpent.toFixed(6)}`);
|
|
1039
|
-
};
|
|
1025
|
+
const meterStreamed = meterFinal;
|
|
1040
1026
|
// A 402 AFTER we attempted payment is a SETTLEMENT failure, not a quote.
|
|
1041
1027
|
// The client-side balance check is advisory, so a funded wallet can still
|
|
1042
1028
|
// fail on-chain / at the facilitator, and the gateway answers the paid
|
package/lib/x402.js
CHANGED
|
@@ -432,49 +432,22 @@ export async function tokenBalance(connection, owner, mintStr) {
|
|
|
432
432
|
export function receiptLine(accept, settle) {
|
|
433
433
|
const x = accept.extra || {};
|
|
434
434
|
const usd = x.billedUsd != null ? `$${Number(x.billedUsd).toFixed(6)}` : `${accept.maxAmountRequired} raw units`;
|
|
435
|
-
// "1.0× cheaper than direct" is a sentence that means nothing, and a user
|
|
436
|
-
// read it as a bug — rightly. Since the gateway repriced to an OpenRouter
|
|
437
|
-
// CEILING, an uncompressed call bills exactly the direct rate, so the ratio
|
|
438
|
-
// is 1.0 by design rather than by accident. Say what actually happened:
|
|
439
|
-
// below 1.05× there is no saving to report, so report the price instead.
|
|
440
|
-
//
|
|
441
|
-
// The saving comes from leCore forwarding fewer tokens. A short body never
|
|
442
|
-
// reaches the spill threshold, so there is nothing to compress and nothing
|
|
443
|
-
// to save — which is worth saying out loud, because the fix on the caller's
|
|
444
|
-
// side is to BIND a corpus, not to change models.
|
|
445
435
|
const billed = Number(x.billedUsd);
|
|
446
436
|
const direct = x.directUsd != null ? Number(x.directUsd) : null;
|
|
447
437
|
const saved = x.savedUsd != null ? Number(x.savedUsd)
|
|
448
438
|
: (Number.isFinite(billed) && Number.isFinite(direct) ? Math.max(0, direct - billed) : null);
|
|
449
439
|
const ratio = x.savesVsDirect != null ? Number(x.savesVsDirect)
|
|
450
440
|
: (Number.isFinite(billed) && billed > 0 && Number.isFinite(direct) ? direct / billed : null);
|
|
451
|
-
//
|
|
452
|
-
//
|
|
453
|
-
//
|
|
454
|
-
// THE BASIS DECIDES THE CLAIM, NOT THE RATIO'S SIZE.
|
|
455
|
-
//
|
|
456
|
-
// This used to gate the discount purely on `ratio >= 1.05`, which is the
|
|
457
|
-
// wrong test: on the `markup` basis the gateway sets directUsd to its OWN
|
|
458
|
-
// forwarded cost so downstream sums stay like-for-like (quote.ts), and
|
|
459
|
-
// savesVsDirect is computed against that same internal number — so the ratio
|
|
460
|
-
// can read well above 1 while billedUsd and directUsd are IDENTICAL.
|
|
461
|
-
// MEASURED 2026-08-28 on a live short-body call: billed $0.0013178, direct
|
|
462
|
-
// $0.0013178, savesVsDirect 1.457 — and this line printed "1.5× cheaper than
|
|
463
|
-
// direct" on a call that by its own two dollar figures saved nothing.
|
|
464
|
-
//
|
|
465
|
-
// The 402's own `description` already guards this correctly by branching on
|
|
466
|
-
// q.pricing; the receipt did not, so the challenge told the truth and the
|
|
467
|
-
// receipt for the same call did not. Only the counterfactual basis — leCore
|
|
468
|
-
// actually spilled, so "what this would have cost sent whole" is a real
|
|
469
|
-
// computed number — has earned the discount claim.
|
|
441
|
+
// Only counterfactual pricing supplies a retail comparison. Other modes
|
|
442
|
+
// may put internal costs in directUsd; they do not establish price parity.
|
|
470
443
|
const counterfactual = x.pricing === 'counterfactual';
|
|
471
444
|
const saves = !counterfactual
|
|
472
|
-
? ' (
|
|
445
|
+
? ' (direct-price comparison unavailable)'
|
|
473
446
|
: ratio != null && ratio >= 1.05
|
|
474
447
|
? ` (${ratio.toFixed(1)}× cheaper than direct)`
|
|
475
448
|
: Number.isFinite(saved) && saved > 0
|
|
476
449
|
? ` ($${saved.toFixed(4)} saved vs direct)`
|
|
477
|
-
: ' (
|
|
450
|
+
: ' (direct-price comparison unavailable)';
|
|
478
451
|
const tx = settle?.transaction || settle?.txHash || settle?.signature;
|
|
479
452
|
const rail = railOf(accept);
|
|
480
453
|
return `paid ${usd}${saves}${rail ? ` · rail ${rail}` : ''}${tx ? ` · tx ${tx}` : ''}`;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "openzoo",
|
|
3
|
-
"version": "0.51.
|
|
3
|
+
"version": "0.51.10",
|
|
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",
|
|
@@ -10,7 +10,14 @@
|
|
|
10
10
|
"files": [
|
|
11
11
|
"bin",
|
|
12
12
|
"lib",
|
|
13
|
-
"README.md"
|
|
13
|
+
"README.md",
|
|
14
|
+
"launcher/main.cjs",
|
|
15
|
+
"launcher/runtime.cjs",
|
|
16
|
+
"launcher/package.json",
|
|
17
|
+
"launcher/savings.cjs",
|
|
18
|
+
"launcher/savings.html",
|
|
19
|
+
"launcher/savings-preload.cjs",
|
|
20
|
+
"launcher/savings-renderer.js"
|
|
14
21
|
],
|
|
15
22
|
"engines": {
|
|
16
23
|
"node": ">=18"
|