openzoo 0.51.6 → 0.51.8

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/README.md CHANGED
@@ -35,6 +35,8 @@ Configuration defaults to `~/.openzoo/chatgpt/config.toml`, with a separate desk
35
35
 
36
36
  `--model <id>` overrides `deepseek-v4-flash`; `--bearer` stores the non-secret placeholder token instead of requiring an environment variable. `--no-launch` writes config only (no download, install, proxy, or launch); `--no-proxy` skips proxy startup; `--print` previews without side effects. Pass app flags after `--`, for example `openzoo chatgpt -- --ozone-platform=wayland`. `OPENZOO_CHATGPT_BIN` selects an explicit executable. Failed downloads, installation, or launch commands return an error.
37
37
 
38
+ The **savings pill** is enabled by default: an orange, draggable overlay shows spent and saved, with estimated direct cost in its expanded view. These are **all-time shared proxy totals**, not per-task ChatGPT billing; ordinary ChatGPT chats are excluded. Negative savings appear as extra cost, and unavailable totals are marked disconnected. The bridge reads `/v1/info` outside the renderer and updates only its own overlay, without modifying the signed app or its security policy. It uses a loopback-only, dynamically assigned Chromium debugging port on the OpenZoo desktop profile, exits after the app is gone for 60 seconds, and logs to `<CODEX_HOME>/desktop/openzoo-savings.log`. Use `--no-pill` to launch without the overlay/debugging flag. Run the command again to attach the pill after a separate app restart.
39
+
38
40
  Installer references: [ChatGPT downloads](https://chatgpt.com/download/), [Windows MSIX deployment](https://learn.chatgpt.com/docs/enterprise/windows-deployment), [Linux packages](https://learn.chatgpt.com/docs/linux/linux-app).
39
41
 
40
42
 
package/bin/openzoo.js CHANGED
@@ -155,6 +155,8 @@ usage:
155
155
  Choose "sign in with API key" (sk-openzoo) if prompted.
156
156
  --deb <file> (Linux) --config /path/to/config.toml
157
157
  --model <id> (deepseek-v4-flash) --wire responses|chat --bearer
158
+ Savings pill: live shared proxy spend / direct estimate / saved
159
+ --no-pill disables the overlay
158
160
  --no-launch (config only) --no-proxy --tunnel --print
159
161
  -- <app flags>
160
162
  npx openzoo openclaw write the zoo into ~/.openclaw/openclaw.json as a model
@@ -0,0 +1,206 @@
1
+ /** Read-only savings overlay for the OpenZoo ChatGPT profile. */
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import os from 'node:os';
5
+ import { spawn } from 'node:child_process';
6
+ import { fileURLToPath } from 'node:url';
7
+ import { cdpSession, listCdpPages } from './ozSpendChip.js';
8
+
9
+ export function chatGptSpendState(info) {
10
+ if (info?.youAreTalkingTo !== 'openzoo proxy') throw new Error('not an OpenZoo proxy');
11
+ const amount = value => typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : null;
12
+ const spent = amount(info.spendUsd), would = amount(info.directUsd);
13
+ const comparable = spent !== null && would !== null && (would > 0 || spent === 0);
14
+ // Do not use savedUsd: older proxies clamp negative savings to zero.
15
+ const saved = comparable ? would - spent : null;
16
+ return { spent, would: comparable ? would : null, saved,
17
+ pct: comparable && would > 0 ? 100 * saved / would : null,
18
+ scope: 'All-time · shared OpenZoo proxy', updatedAt: Date.now(), online: true };
19
+ }
20
+
21
+ /** Self-contained renderer. Only owns its shadow-root overlay, never chat content. */
22
+ export function renderChatGptSpend(state) {
23
+ let host = document.getElementById('openzoo-chatgpt-spend');
24
+ if (!document.body) return false;
25
+ if (!host) {
26
+ host = document.createElement('div');
27
+ host.id = 'openzoo-chatgpt-spend';
28
+ host.style.cssText = 'position:fixed;right:20px;bottom:90px;z-index:2147483646;max-width:calc(100vw - 24px);-webkit-app-region:no-drag';
29
+ const root = host.attachShadow({ mode: 'open' });
30
+ root.innerHTML = `<style>
31
+ :host{font:12px/1.5 system-ui,sans-serif;color:#fafafa;color-scheme:dark}
32
+ details{max-width:min(470px,calc(100vw - 24px))}
33
+ summary{list-style:none;cursor:grab;touch-action:none;border-radius:999px;background:#e85d1c;color:white;padding:8px 14px;box-shadow:0 4px 18px #0006;font-weight:650;overflow-wrap:anywhere}
34
+ summary::-webkit-details-marker{display:none}summary:focus-visible{outline:2px solid white;outline-offset:3px}
35
+ section{margin-top:8px;padding:12px 14px;border:1px solid #ffffff24;border-radius:14px;background:#18181bef;box-shadow:0 4px 18px #0005}
36
+ dl{display:grid;grid-template-columns:1fr auto;gap:5px 24px;margin:10px 0}dd{margin:0;font-variant-numeric:tabular-nums}
37
+ small{display:block;color:#bfbfc7}p{margin:8px 0 0;color:#bfbfc7}button{margin-top:10px;border:1px solid #ffffff30;border-radius:8px;background:transparent;color:inherit;padding:4px 10px;cursor:pointer}
38
+ </style><details><summary role="button" aria-expanded="false" aria-label="OpenZoo costs and savings"></summary><section>
39
+ <small data-scope></small><dl><dt>Spent</dt><dd data-spent></dd><dt>Would’ve cost · estimate</dt><dd data-would></dd><dt data-saving-label>Saved</dt><dd data-saved></dd></dl>
40
+ <small data-status role="status"></small><p>Direct cost is the proxy’s counterfactual estimate. Totals include other apps using this proxy. Ordinary ChatGPT chats are not included.</p>
41
+ <button type="button">Reset position</button></section></details>`;
42
+ const summary = root.querySelector('summary');
43
+ let drag = null, moved = false;
44
+ const place = (left, top) => {
45
+ const r = host.getBoundingClientRect();
46
+ host.style.left = Math.max(8, Math.min(left, innerWidth - r.width - 8)) + 'px';
47
+ host.style.top = Math.max(8, Math.min(top, innerHeight - r.height - 8)) + 'px';
48
+ host.style.right = host.style.bottom = 'auto';
49
+ };
50
+ document.body.appendChild(host);
51
+ try { const p = JSON.parse(localStorage.getItem('openzoo-chatgpt-spend-position')); if (p && Number.isFinite(p.x) && Number.isFinite(p.y)) place(p.x, p.y); } catch { /* storage unavailable */ }
52
+ summary.addEventListener('pointerdown', e => {
53
+ if (e.button !== 0) return;
54
+ const r = host.getBoundingClientRect();
55
+ drag = { x: e.clientX, y: e.clientY, left: r.left, top: r.top }; moved = false;
56
+ summary.setPointerCapture(e.pointerId);
57
+ });
58
+ summary.addEventListener('pointermove', e => {
59
+ if (!drag) return;
60
+ const dx = e.clientX - drag.x, dy = e.clientY - drag.y;
61
+ if (Math.abs(dx) + Math.abs(dy) > 4) moved = true;
62
+ if (moved) place(drag.left + dx, drag.top + dy);
63
+ });
64
+ summary.addEventListener('pointerup', () => {
65
+ if (drag && moved) { const r = host.getBoundingClientRect(); try { localStorage.setItem('openzoo-chatgpt-spend-position', JSON.stringify({ x: r.left, y: r.top })); } catch {} }
66
+ drag = null;
67
+ });
68
+ summary.addEventListener('pointercancel', () => { drag = null; });
69
+ summary.addEventListener('click', e => { if (moved) { e.preventDefault(); moved = false; } });
70
+ root.querySelector('button').addEventListener('click', () => {
71
+ try { localStorage.removeItem('openzoo-chatgpt-spend-position'); } catch {}
72
+ host.style.left = host.style.top = 'auto'; host.style.right = '20px'; host.style.bottom = '90px';
73
+ });
74
+ root.querySelector('details').addEventListener('toggle', () => { summary.setAttribute('aria-expanded', String(root.querySelector('details').open)); if (host.style.left !== 'auto' && host.style.left) { const r = host.getBoundingClientRect(); place(r.left, r.top); } });
75
+ addEventListener('resize', () => { if (host.isConnected && host.style.left !== 'auto' && host.style.left) { const r = host.getBoundingClientRect(); place(r.left, r.top); } });
76
+ // A dead bridge must not leave apparently live numbers indefinitely.
77
+ const timer = setInterval(() => {
78
+ if (!host.isConnected) { clearInterval(timer); return; }
79
+ if (Date.now() - (host.__ozUpdate || 0) > 12000) {
80
+ root.querySelector('[data-status]').textContent = 'Disconnected · last known totals';
81
+ root.querySelector('summary').textContent = 'OpenZoo · disconnected';
82
+ }
83
+ }, 3000);
84
+ }
85
+ const root = host.shadowRoot;
86
+ if (!root) return false;
87
+ const usd = n => n === null || !Number.isFinite(n) ? '—' : '$' + Math.abs(n).toFixed(Math.abs(n) < 0.01 ? 4 : 2);
88
+ const more = state.saved !== null && state.saved < 0;
89
+ const savings = state.saved === null ? 'Savings —' : `${more ? 'Extra cost' : 'Saved'} ${usd(state.saved)}`;
90
+ root.querySelector('summary').textContent = state.online ? `OpenZoo · ${usd(state.spent)} spent · ${savings}` : 'OpenZoo · disconnected';
91
+ root.querySelector('[data-spent]').textContent = usd(state.spent);
92
+ root.querySelector('[data-would]').textContent = usd(state.would);
93
+ root.querySelector('[data-saving-label]').textContent = more ? 'Extra cost' : 'Saved';
94
+ root.querySelector('[data-saved]').textContent = usd(state.saved) + (state.pct === null ? '' : ` (${Math.abs(state.pct).toFixed(1)}%)`);
95
+ root.querySelector('[data-scope]').textContent = state.scope;
96
+ root.querySelector('[data-status]').textContent = state.online ? 'Updated ' + new Date(state.updatedAt).toLocaleTimeString() : 'Proxy unavailable · last known totals';
97
+ host.__ozUpdate = Date.now();
98
+ return true;
99
+ }
100
+
101
+ export function chatGptSpendSource(state) {
102
+ return `(${renderChatGptSpend.toString()})(${JSON.stringify(state).replace(/</g, '\\u003c')})`;
103
+ }
104
+
105
+ export function readChatGptDebugPort(profile) {
106
+ try {
107
+ const n = Number(fs.readFileSync(path.join(profile, 'DevToolsActivePort'), 'utf8').split(/\r?\n/)[0]);
108
+ return Number.isInteger(n) && n > 0 && n < 65536 ? n : null;
109
+ } catch { return null; }
110
+ }
111
+
112
+ export function isChatGptRenderer(target, port) {
113
+ try {
114
+ const ws = new URL(target.webSocketDebuggerUrl);
115
+ if (ws.protocol !== 'ws:' || ws.hostname !== '127.0.0.1' && ws.hostname !== 'localhost' || Number(ws.port) !== port) return false;
116
+ const url = new URL(target.url);
117
+ return target.type === 'page' && (url.protocol === 'file:' && /\/index\.html$/.test(url.pathname) || url.protocol === 'app:' && /codex|chatgpt/.test(url.hostname));
118
+ } catch { return false; }
119
+ }
120
+
121
+ /** Host-side polling avoids changing the signed app or relaxing renderer CSP. */
122
+ export async function watchChatGptSpend({ profile, proxyPort, fetchImpl = fetch, connect = cdpSession,
123
+ sleep = ms => new Promise(r => setTimeout(r, ms)), stopped = () => false, log = console.error } = {}) {
124
+ const sessions = new Map();
125
+ let misses = 0;
126
+ let state = { spent: null, would: null, saved: null, pct: null, scope: 'All-time · shared OpenZoo proxy', online: false, updatedAt: 0 };
127
+ try {
128
+ while (!stopped() && misses < 20) {
129
+ const port = readChatGptDebugPort(profile);
130
+ const pages = port ? (await listCdpPages(port, fetchImpl)).filter(t => isChatGptRenderer(t, port)) : [];
131
+ misses = pages.length ? 0 : misses + 1;
132
+ const live = new Set(pages.map(t => t.webSocketDebuggerUrl));
133
+ for (const [id, session] of sessions) if (!live.has(id)) { session.close(); sessions.delete(id); }
134
+ try {
135
+ const r = await fetchImpl(`http://127.0.0.1:${proxyPort}/v1/info`, { signal: AbortSignal.timeout(2000) });
136
+ if (!r.ok) throw new Error(`proxy HTTP ${r.status}`);
137
+ state = chatGptSpendState(await r.json());
138
+ } catch { state = { ...state, online: false }; }
139
+ for (const page of pages) {
140
+ const id = page.webSocketDebuggerUrl;
141
+ try {
142
+ if (!sessions.has(id)) sessions.set(id, await connect(id, { timeoutMs: 2500, sendOrigin: false }));
143
+ const result = await sessions.get(id).send('Runtime.evaluate', { expression: chatGptSpendSource(state), returnByValue: true });
144
+ if (result.exceptionDetails) throw new Error('savings pill renderer evaluation failed');
145
+ } catch (e) { sessions.get(id)?.close(); sessions.delete(id); log(`openzoo: savings pill: ${e.message}`); }
146
+ }
147
+ await sleep(3000);
148
+ }
149
+ if (misses >= 20) log('openzoo: savings pill stopped: no ChatGPT renderer found for 60s');
150
+ } finally { for (const session of sessions.values()) session.close(); }
151
+ }
152
+
153
+ /** A current launcher supplies a native pill even when the chat app disables debugging. */
154
+ export function nativeSavingsLauncher({ platform = process.platform, home = os.homedir(), env = process.env, exists = fs.existsSync } = {}) {
155
+ const roots = platform === 'darwin'
156
+ ? ['/Applications/OpenZoo Launcher.app/Contents', path.join(home, 'Applications/OpenZoo Launcher.app/Contents')]
157
+ : platform === 'win32' ? [path.join(env.LOCALAPPDATA || home, 'Programs/openzoo-launcher'), path.join(env.LOCALAPPDATA || home, 'Programs/OpenZoo Launcher')] : [];
158
+ for (const root of roots) {
159
+ const resources = path.join(root, platform === 'darwin' ? 'Resources' : 'resources');
160
+ const executable = platform === 'darwin' ? path.join(root, 'MacOS/OpenZoo Launcher') : path.join(root, 'OpenZoo Launcher.exe');
161
+ if (exists(path.join(resources, 'openzoo-overlay.json')) && exists(executable)) return executable;
162
+ }
163
+ return null;
164
+ }
165
+
166
+ export async function startChatGptSpend({ profile, proxyPort, spawnImpl = spawn } = {}) {
167
+ const native = nativeSavingsLauncher();
168
+ if (native && proxyPort === 8402) {
169
+ const child = spawnImpl(native, ['--savings-only'], { detached: true, stdio: 'ignore' });
170
+ await new Promise((resolve, reject) => { child.once('spawn', resolve); child.once('error', reject); });
171
+ child.unref();
172
+ return;
173
+ }
174
+ fs.mkdirSync(profile, { recursive: true });
175
+ const fd = fs.openSync(path.join(profile, 'openzoo-savings.log'), 'a', 0o600);
176
+ try {
177
+ const child = spawnImpl(process.execPath, [fileURLToPath(import.meta.url), '--watch', profile, String(proxyPort)], { detached: true, stdio: ['ignore', fd, fd] });
178
+ await new Promise((resolve, reject) => { child.once('spawn', resolve); child.once('error', reject); });
179
+ child.unref();
180
+ } finally { fs.closeSync(fd); }
181
+ }
182
+
183
+ /** One watcher per profile, including concurrent launcher invocations. */
184
+ export function claimSpendWatcher(profile) {
185
+ const lock = path.join(profile, 'openzoo-savings.lock');
186
+ try {
187
+ const pid = Number(fs.readFileSync(lock, 'utf8'));
188
+ if (Number.isInteger(pid) && pid > 0) {
189
+ try { process.kill(pid, 0); return null; } catch (e) { if (e.code !== 'ESRCH') return null; }
190
+ } else if (Date.now() - fs.statSync(lock).mtimeMs < 60000) return null;
191
+ fs.unlinkSync(lock);
192
+ } catch (e) { if (e.code !== 'ENOENT') throw e; }
193
+ let fd;
194
+ try { fd = fs.openSync(lock, 'wx', 0o600); } catch (e) { if (e.code === 'EEXIST') return null; throw e; }
195
+ fs.writeFileSync(fd, String(process.pid)); fs.closeSync(fd);
196
+ return () => { try { if (fs.readFileSync(lock, 'utf8') === String(process.pid)) fs.unlinkSync(lock); } catch {} };
197
+ }
198
+
199
+ if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url) && process.argv[2] === '--watch') {
200
+ const release = claimSpendWatcher(process.argv[3]);
201
+ if (release) {
202
+ process.once('exit', release);
203
+ watchChatGptSpend({ profile: process.argv[3], proxyPort: Number(process.argv[4]) })
204
+ .catch(e => { console.error(e.message); process.exitCode = 1; }).finally(release);
205
+ }
206
+ }
package/lib/chatgpt.js CHANGED
@@ -11,6 +11,7 @@ import fs from 'node:fs';
11
11
  import os from 'node:os';
12
12
  import path from 'node:path';
13
13
  import { config } from './config.js';
14
+ import { startChatGptSpend } from './chatgpt-spend.js';
14
15
  import { installChatGpt, windowsChatGptApp, runCommand } from './chatgpt-install.js';
15
16
 
16
17
  export const PROVIDER_ID = 'openzoo';
@@ -245,9 +246,9 @@ async function ensureProxy({ tunnel }) {
245
246
  export async function setupChatGpt(argv = [], dependencies = {}) {
246
247
  const { platform = process.platform, home = os.homedir(), env: sourceEnv = process.env,
247
248
  resolve = resolveChatGptApp, install = installChatGpt, proxy = ensureProxy,
248
- launch = launchChatGpt, findWindows = windowsChatGptApp } = dependencies;
249
+ launch = launchChatGpt, findWindows = windowsChatGptApp, overlay = startChatGptSpend } = dependencies;
249
250
  const args = [...argv];
250
- const opt = { model: null, deb: null, wireApi: 'responses', bearer: false, launch: true, proxy: true, tunnel: false, print: false, configPath: null, rest: [] };
251
+ const opt = { model: null, deb: null, wireApi: 'responses', bearer: false, launch: true, proxy: true, pill: true, tunnel: false, print: false, configPath: null, rest: [] };
251
252
  while (args.length) {
252
253
  const a = args.shift();
253
254
  if (a === '--') { opt.rest.push(...args); break; }
@@ -261,6 +262,7 @@ export async function setupChatGpt(argv = [], dependencies = {}) {
261
262
  else if (a === '--config') opt.configPath = value();
262
263
  else if (a === '--bearer') opt.bearer = true;
263
264
  else if (a === '--no-launch') opt.launch = false;
265
+ else if (a === '--no-pill') opt.pill = false;
264
266
  else if (a === '--no-proxy') opt.proxy = false;
265
267
  else if (a === '--tunnel') opt.tunnel = true;
266
268
  else if (a === '--print') opt.print = true;
@@ -319,7 +321,14 @@ export async function setupChatGpt(argv = [], dependencies = {}) {
319
321
  console.log(`launching ${app}`);
320
322
  console.log(` Codex / agent mode → the zoo, paid per turn by x402. Choose "sign in with API key" (any key).`);
321
323
  console.log(' the plain ChatGPT chat tab still talks to chatgpt.com — that half is not API-shaped.');
322
- await launch(app, opt.rest, { env, platform });
324
+ const appArgs = opt.pill ? ['--remote-debugging-address=127.0.0.1', '--remote-debugging-port=0', ...opt.rest] : opt.rest;
325
+ await launch(app, appArgs, { env, platform });
326
+ if (opt.pill) {
327
+ try {
328
+ await overlay({ profile: env.CODEX_ELECTRON_USER_DATA_PATH, proxyPort: config.port });
329
+ console.log('savings pill enabled · spent / estimated direct cost / saved · shared proxy totals');
330
+ } catch (e) { console.error(`openzoo: savings pill unavailable: ${e.message}`); }
331
+ }
323
332
  return { file, model, app, launched: true };
324
333
  }
325
334
 
package/lib/evm.js CHANGED
@@ -9,7 +9,7 @@ const ERC20_ABI = [
9
9
 
10
10
  const evmClients = new Map();
11
11
  function clientFor(rpcUrl) {
12
- if (!evmClients.has(rpcUrl)) evmClients.set(rpcUrl, createPublicClient({ transport: http(rpcUrl) }));
12
+ if (!evmClients.has(rpcUrl)) evmClients.set(rpcUrl, createPublicClient({ transport: http(rpcUrl, { timeout: 4000, retryCount: 0 }) }));
13
13
  return evmClients.get(rpcUrl);
14
14
  }
15
15
 
@@ -0,0 +1,17 @@
1
+ /** Coalesce live reads, retain unknowns, and never cache an empty balance for a minute. */
2
+ export function liveBalance(read, { ttlMs = 3000, now = Date.now } = {}) {
3
+ let value = null, at = 0, inflight;
4
+ return {
5
+ get value() { return value; },
6
+ get checkedAt() { return at || null; },
7
+ async refresh(force = false) {
8
+ if (inflight) return inflight;
9
+ if (!force && at && now() - at < ttlMs) return value;
10
+ inflight = Promise.resolve().then(read).then(next => {
11
+ if (typeof next !== 'number' || !Number.isFinite(next) || next < 0) throw new Error('Balance unavailable');
12
+ value = next; at = now(); return value;
13
+ }).catch(() => value).finally(() => { inflight = null; });
14
+ return inflight;
15
+ },
16
+ };
17
+ }
package/lib/info.js CHANGED
@@ -259,14 +259,12 @@ export { CREDIT_TIMEOUT_MS };
259
259
 
260
260
  export async function creditBalance() {
261
261
  const { withNamespace } = await import('./namespace.js');
262
- try {
263
- const r = await fetch(`${config.apiBase}/v1/credits`, {
264
- headers: withNamespace({}),
265
- signal: AbortSignal.timeout(CREDIT_TIMEOUT_MS),
266
- });
267
- const j = await r.json();
268
- return Number(j.balanceUsd) || 0;
269
- } catch {
270
- return 0;
271
- }
262
+ const r = await fetch(`${config.apiBase}/v1/credits`, {
263
+ headers: withNamespace({}), cache: 'no-store',
264
+ signal: AbortSignal.timeout(CREDIT_TIMEOUT_MS),
265
+ });
266
+ if (!r.ok) throw new Error('Credit balance is temporarily unavailable');
267
+ const j = await r.json();
268
+ if (typeof j.balanceUsd !== 'number' || !Number.isFinite(j.balanceUsd) || j.balanceUsd < 0) throw new Error('Credit balance is temporarily unavailable');
269
+ return j.balanceUsd;
272
270
  }
@@ -758,7 +758,7 @@ function decodeWsFrames(buf) {
758
758
  return { frames, rest };
759
759
  }
760
760
 
761
- export function cdpSession(wsUrl, { timeoutMs = 8000 } = {}) {
761
+ export function cdpSession(wsUrl, { timeoutMs = 8000, sendOrigin = true } = {}) {
762
762
  return new Promise((resolve, reject) => {
763
763
  let u;
764
764
  try { u = new URL(wsUrl); } catch (e) { reject(e); return; }
@@ -793,7 +793,7 @@ export function cdpSession(wsUrl, { timeoutMs = 8000 } = {}) {
793
793
  + 'Connection: Upgrade\r\n'
794
794
  + 'Sec-WebSocket-Version: 13\r\n'
795
795
  + `Sec-WebSocket-Key: ${key}\r\n`
796
- + `Origin: http://${u.host}\r\n`
796
+ + (sendOrigin ? `Origin: http://${u.host}\r\n` : '')
797
797
  + '\r\n',
798
798
  );
799
799
  });
package/lib/pay.js CHANGED
@@ -27,6 +27,10 @@ export class QuoteTooHighError extends Error {
27
27
  }
28
28
  }
29
29
 
30
+ export class BalanceUnavailableError extends Error {
31
+ constructor() { super('We couldn’t check your balance right now. Please try again. You don’t need to add funds again.'); }
32
+ }
33
+
30
34
  export class UnderfundedError extends Error {
31
35
  constructor(accept, usdcUi, address, { line } = {}) {
32
36
  const usd = Number(accept?.extra?.billedUsd);
@@ -149,14 +153,10 @@ export function chainName(network) {
149
153
 
150
154
  const memoKey = (a) => `${a?.network || ''}|${a?.asset || ''}`;
151
155
 
152
- /** lastGood first, known-underfunded last (never dropped — only deprioritised). */
153
- export function orderCandidatesByMemory(cands, now = Date.now()) {
154
- const skip = (c) => (underfundedUntil.get(memoKey(c)) ?? 0) > now;
156
+ /** Prefer a confirmed successful payer; recheck other balances on every request. */
157
+ export function orderCandidatesByMemory(cands) {
155
158
  const good = (c) => lastGoodAsset && memoKey(c) === lastGoodAsset;
156
- // Stable partition: proven payer, then untried, then recently-underfunded. The
157
- // last group is KEPT so a wallet funded seconds ago is still reachable in the
158
- // same call if nothing else works.
159
- return [...cands].sort((a, b) => (good(b) - good(a)) || (skip(a) - skip(b)));
159
+ return [...cands].sort((a, b) => good(b) - good(a));
160
160
  }
161
161
 
162
162
  /** Test seam: forget everything the rail memory learned. */
@@ -259,7 +259,7 @@ export class PayClient {
259
259
  const rail = railOf(accept);
260
260
  if (rail === 'solana') {
261
261
  const need = BigInt(accept.maxAmountRequired);
262
- const bal = await cachedTokenBalance(this.connection, this.keypair.publicKey, accept.asset, { force: true });
262
+ const bal = await cachedTokenBalance(this.connection, this.keypair.publicKey, accept.asset, { force: true }).catch(() => { throw new BalanceUnavailableError(); });
263
263
  // SHORT IS SHORT. The 402 quotes the raw native mint, so there is nothing
264
264
  // to convert and no pool to walk: the wallet either holds the asset or it
265
265
  // does not. This branch used to run resolvePool + poolState + a wrap
@@ -302,7 +302,7 @@ export class PayClient {
302
302
  });
303
303
  }
304
304
  if (owner) {
305
- const bal = await evmTokenBalance({ rpcUrl, token: accept.asset, owner }).catch(() => null);
305
+ const bal = await evmTokenBalance({ rpcUrl, token: accept.asset, owner }).catch(() => { throw new BalanceUnavailableError(); });
306
306
  if (bal !== null && bal < BigInt(accept.maxAmountRequired)) {
307
307
  // Robinhood used to quote an ERC-4626 vault twin here and the shim
308
308
  // ran approve+deposit to mint it. The gateway now quotes canonical
@@ -377,6 +377,7 @@ export class PayClient {
377
377
  let accept = null;
378
378
  let payment = null;
379
379
  const fundErrs = [];
380
+ let balanceError = null;
380
381
  let tooHigh = null;
381
382
  for (const cand of candidates) {
382
383
  const billedUsd = Number(cand?.extra?.billedUsd ?? NaN);
@@ -388,13 +389,13 @@ export class PayClient {
388
389
  payment = await this.buildPaymentFor(cand, onStage, quote);
389
390
  if (fundErrs.length) railNote(`paying on ${railLabel(cand)}`);
390
391
  accept = cand;
391
- lastGoodAsset = memoKey(cand);
392
392
  underfundedUntil.delete(memoKey(cand));
393
393
  if (railOf(cand) === 'solana') {
394
394
  try { debitCachedBalance(this.keypair.publicKey, cand.asset, BigInt(cand.maxAmountRequired)); } catch { /* advisory */ }
395
395
  }
396
396
  break;
397
397
  } catch (e) {
398
+ if (e instanceof BalanceUnavailableError) { balanceError = e; continue; }
398
399
  if (!(e instanceof UnderfundedError)) throw e;
399
400
  // Remember it so the next call does not re-pay the discovery cost.
400
401
  underfundedUntil.set(memoKey(cand), Date.now() + RAIL_MEMO_MS);
@@ -411,6 +412,7 @@ export class PayClient {
411
412
  }
412
413
  }
413
414
  if (!payment) {
415
+ if (balanceError) throw balanceError;
414
416
  if (!fundErrs.length && tooHigh) throw tooHigh;
415
417
  if (fundErrs.length === 1) throw fundErrs[0].err;
416
418
  throw new UnderfundedError(candidates[0], null, this.address, {
@@ -452,11 +454,11 @@ export class PayClient {
452
454
  });
453
455
  if (again.status !== 402) {
454
456
  accept = cand; payment = retry; response = again;
455
- lastGoodAsset = memoKey(cand);
457
+ if (again.ok) lastGoodAsset = memoKey(cand);
456
458
  break;
457
459
  }
458
460
  } catch (e) {
459
- if (!(e instanceof UnderfundedError)) throw e;
461
+ if (!(e instanceof UnderfundedError) && !(e instanceof BalanceUnavailableError)) throw e;
460
462
  }
461
463
  }
462
464
  }
@@ -513,6 +515,9 @@ export class PayClient {
513
515
  this._rebuilt = false;
514
516
  }
515
517
  }
518
+ if (response.ok) lastGoodAsset = memoKey(accept);
519
+ else if (lastGoodAsset === memoKey(accept)) lastGoodAsset = null;
520
+
516
521
  // NEVER PRESENT OUR OWN SIGNATURE AS THE TRANSACTION.
517
522
  //
518
523
  // This fell back to `{ signature: payment.ownerSignature }`, and on Solana
@@ -0,0 +1,15 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ /** Report a failed turn through the client's response stream, not transport-error prose. */
3
+ export function paymentError(res, req, init, status, message) {
4
+ let body = {};
5
+ try { body = JSON.parse(String(init?.body || '{}')); } catch {}
6
+ if (/\/responses(?:\?|$)/.test(req.url || '') && body.stream === true) {
7
+ const response = { id: `resp_${randomUUID()}`, object: 'response', created_at: Math.floor(Date.now()/1000), status: 'failed',
8
+ error: { code: 'server_error', message }, output: [], usage: null, model: body.model || '', incomplete_details: null };
9
+ res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-cache' });
10
+ res.end(`event: response.failed\ndata: ${JSON.stringify({ type: 'response.failed', sequence_number: 0, response })}\n\n`);
11
+ return;
12
+ }
13
+ res.writeHead(status, { 'content-type': 'application/json' });
14
+ res.end(JSON.stringify({ error: { message } }));
15
+ }
package/lib/proxy.js CHANGED
@@ -1,3 +1,5 @@
1
+ import { liveBalance } from './funding-status.js';
2
+ import { paymentError } from './payment-error.js';
1
3
  import { readFileSync, appendFileSync, mkdirSync } from 'node:fs';
2
4
  import os from 'node:os';
3
5
  import path from 'node:path';
@@ -8,8 +10,8 @@ import {
8
10
  config, FUNDING_ASSETS, EVM_FUNDING_ASSETS, evmRpcFor, fundingLine, liveRails, railFundingHint, railFundingAddresses, unfundableRails, RAIL_FUNDING,
9
11
  } from './config.js';
10
12
  import { execSync } from 'node:child_process';
11
- import { PayClient, QuoteTooHighError, UnderfundedError } from './pay.js';
12
- import { withOnrampLink, settleFailCopy } from './stripeOnramp.js';
13
+ import { PayClient, QuoteTooHighError, UnderfundedError, BalanceUnavailableError, resetBalanceCache, resetRailMemory } from './pay.js';
14
+ import { withOnrampLink, settleFailCopy, whopFundBlurb } from './stripeOnramp.js';
13
15
  import { tokenBalance } from './x402.js';
14
16
  import { evmTokenBalance } from './evm.js';
15
17
  import { autoContext } from './autobind.js';
@@ -451,7 +453,7 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
451
453
  let refreshBalances = async () => {};
452
454
  let refreshPending = false;
453
455
  const scheduleRefresh = (ms) => {
454
- if (silent || refreshPending) return;
456
+ if (refreshPending) return;
455
457
  refreshPending = true;
456
458
  const t = setTimeout(async () => { refreshPending = false; await refreshBalances(); }, ms);
457
459
  t.unref?.();
@@ -471,11 +473,10 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
471
473
  // billed for ONLY those calls whose real cost we learned — the honest
472
474
  // numerator for markupX.
473
475
  let billedWithActual = 0;
474
- // CREDIT, CACHED. Refreshed at most every 20s and served stale in between —
476
+ // CREDIT, CACHED. Refreshed every few seconds and served stale in between —
475
477
  // a status line must never add latency to the thing it is describing.
476
478
  let creditUsd = null;
477
479
  let creditAt = 0;
478
- let creditInflight = null;
479
480
  let lastPrices = {};
480
481
  let pricesAt = 0;
481
482
  // WALLET BALANCE, CACHED LIKE CREDIT.
@@ -490,40 +491,38 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
490
491
  // behind it — the same contract refreshCredit() keeps.
491
492
  let walletUsd = null;
492
493
  let walletAt = 0;
493
- let walletInflight = null;
494
+ const walletStatus = liveBalance(async () => {
495
+ const snap = await snapshotBalances(client);
496
+ if (!snap.length) throw new Error('Balances unavailable');
497
+ const previous = new Map((lastSnap || []).map(b => [`${b.chain}:${b.symbol}`, b]));
498
+ for (const b of snap) {
499
+ const old = previous.get(`${b.chain}:${b.symbol}`);
500
+ if (b.ui > (old?.ui ?? 0)) { resetBalanceCache(); resetRailMemory(); }
501
+ previous.set(`${b.chain}:${b.symbol}`, b);
502
+ }
503
+ lastSnap = [...previous.values()];
504
+ return priceHoldings(lastSnap, lastPrices).chainUsd;
505
+ });
506
+ const creditStatus = liveBalance(creditBalance);
494
507
  const refreshWallet = async (force = false) => {
495
- if (!force && Date.now() - walletAt < 60000 && walletUsd != null) return walletUsd;
496
- if (walletInflight) return walletInflight;
497
- walletInflight = (async () => {
498
- try {
499
- const { affordableUsd } = await import('./info.js');
500
- const v = await affordableUsd();
501
- if (Number.isFinite(v)) { walletUsd = v; walletAt = Date.now(); }
502
- } catch { /* keep last known */ }
503
- walletInflight = null;
504
- return walletUsd;
505
- })();
506
- return walletInflight;
508
+ walletUsd = await walletStatus.refresh(force);
509
+ walletAt = walletStatus.checkedAt;
510
+ return walletUsd;
507
511
  };
508
512
  const refreshCredit = async (force = false) => {
509
- if (!force && Date.now() - creditAt < 20000 && creditUsd != null) return creditUsd;
510
- if (creditInflight) return creditInflight;
511
- creditInflight = (async () => {
512
- try {
513
- creditUsd = await creditBalance();
514
- creditAt = Date.now();
515
- } catch { /* keep last known */ }
516
- creditInflight = null;
517
- return creditUsd;
518
- })();
519
- return creditInflight;
513
+ creditUsd = await creditStatus.refresh(force);
514
+ creditAt = creditStatus.checkedAt;
515
+ return creditUsd;
520
516
  };
517
+ let pricesInflight = null;
521
518
  const refreshPrices = async () => {
522
- if (Date.now() - pricesAt < 60000 && Object.keys(lastPrices).length) return lastPrices;
523
- try {
524
- lastPrices = await quotedPrices();
519
+ if (Date.now() - pricesAt < 60000) return lastPrices;
520
+ if (!pricesInflight) {
525
521
  pricesAt = Date.now();
526
- } catch { /* keep last */ }
522
+ pricesInflight = quotedPrices().then(prices => { lastPrices = prices; })
523
+ .catch(() => {}).finally(() => { pricesInflight = null; });
524
+ }
525
+ await pricesInflight;
527
526
  return lastPrices;
528
527
  };
529
528
  const walletMoney = () => priceHoldings(lastSnap || [], lastPrices);
@@ -570,6 +569,13 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
570
569
  return;
571
570
  }
572
571
 
572
+ if (req.method === 'GET' && (req.url || '').split('?')[0] === '/v1/funding') {
573
+ await Promise.all([refreshCredit(true), refreshWallet(true), refreshBalances()]);
574
+ res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store' });
575
+ res.end(JSON.stringify({ creditUsd, walletUsd, creditCheckedAt: creditAt || null, walletCheckedAt: walletAt || null }));
576
+ return;
577
+ }
578
+
573
579
  if ((req.url || '').split('?')[0] === '/mcp') {
574
580
  try {
575
581
  const { handleMcpRequest } = await import('./mcphttp.js');
@@ -1068,21 +1074,24 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
1068
1074
  // USDC on Base — so every 402 after a payment carries all three, not
1069
1075
  // only the ones the gateway managed to classify as insufficient funds.
1070
1076
  msg = await withOnrampLink(msg, { solana: client.address, evm: client.evmAddress, usd, code: copy.code, force: true });
1071
- log(/ties to your account/i.test(msg) ? 'onramp: whop + copy-paste solana' : `402: ${msg.slice(0, 140)}`);
1072
- jsonErr(res, paid ? copy.status : 402, msg);
1077
+ log(/At checkout/i.test(msg) ? 'onramp: whop + copy-paste solana' : `402: ${msg.slice(0, 140)}`);
1078
+ log(`payment diagnostic: ${copy.reason}`);
1079
+ paymentError(res, req, init, paid ? copy.status : 402, msg);
1073
1080
  return;
1074
1081
  }
1075
1082
  await relay(res, response, meterStreamed);
1076
1083
  } catch (err) {
1077
1084
  if (err instanceof QuoteTooHighError) {
1078
1085
  log(err.message);
1079
- jsonErr(res, 402, err.message, { quote: err.quote });
1086
+ paymentError(res, req, init, 400, 'This request exceeds your spending limit. Try a shorter request or change your limit.');
1080
1087
  } else if (err instanceof UnderfundedError) {
1081
1088
  const msg = await withOnrampLink(err.message, {
1082
1089
  solana: client.address, evm: client.evmAddress, usd: Number(err.accept?.extra?.billedUsd),
1083
1090
  });
1084
1091
  log(msg);
1085
- jsonErr(res, 402, msg);
1092
+ paymentError(res, req, init, 402, `Your available balance does not cover this request yet.\n\n${whopFundBlurb(client.address, client.evmAddress)}`);
1093
+ } else if (err instanceof BalanceUnavailableError) {
1094
+ paymentError(res, req, init, 503, `${err.message}\n\n${whopFundBlurb(client.address, client.evmAddress)}`);
1086
1095
  } else {
1087
1096
  // "fetch failed" alone is undiagnosable — undici hides the real
1088
1097
  // network error in `cause`. Surface it or every transport hiccup looks
@@ -1092,7 +1101,7 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
1092
1101
  const detail = raw;
1093
1102
  log(`proxy error: ${detail}`);
1094
1103
  if (process.env.OPENZOO_DEBUG) console.error(err.stack);
1095
- jsonErr(res, 502, `openzoo proxy error: ${detail}`);
1104
+ paymentError(res, req, init, 502, 'OpenZoo couldn’t connect right now. Please try again.');
1096
1105
  }
1097
1106
  }
1098
1107
  });
@@ -1130,6 +1139,13 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
1130
1139
  }
1131
1140
  }
1132
1141
 
1142
+ // Funding checks run for desktop/background launches as well as terminal sessions.
1143
+ refreshBalances = () => refreshWallet(true);
1144
+ refreshWallet(); refreshCredit(); refreshPrices();
1145
+ const fundingTimer = setInterval(() => { refreshCredit(); refreshWallet(); refreshPrices(); }, 3000);
1146
+ fundingTimer.unref?.();
1147
+ server.on('close', () => clearInterval(fundingTimer));
1148
+
1133
1149
  // AUTO-PREPAY. Paying on-chain per call is where the latency lives: credit
1134
1150
  // is applied automatically server-side whenever a balance covers the quote,
1135
1151
  // so buying it once makes every later call skip verify+settle entirely.
@@ -1183,31 +1199,6 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
1183
1199
  } catch { /* RPC hiccup: balance is advisory */ }
1184
1200
  refreshCredit();
1185
1201
  refreshPrices();
1186
- // LIVE REFRESH: poll on an interval (and shortly after each paid call),
1187
- // print ONLY on change, and call out arrivals explicitly.
1188
- refreshBalances = async () => {
1189
- try {
1190
- const snap = await snapshotBalances(client);
1191
- if (!snap.length) return;
1192
- const prev = new Map((lastSnap || []).map((b) => [`${b.chain}:${b.symbol}`, b.ui]));
1193
- const changed = snap.some((b) => Math.abs((prev.get(`${b.chain}:${b.symbol}`) ?? 0) - b.ui) > 1e-9)
1194
- || snap.length !== (lastSnap || []).length;
1195
- if (!changed) return;
1196
- if (lastSnap) {
1197
- for (const b of snap) {
1198
- const gain = b.ui - (prev.get(`${b.chain}:${b.symbol}`) ?? 0);
1199
- if (gain > 1e-9) console.log(`funding arrived: +${gain.toFixed(6)} ${b.symbol}${b.chain !== 'solana' ? ` (${b.chain})` : ''}`);
1200
- }
1201
- }
1202
- lastSnap = snap;
1203
- console.log(`balance: ${balanceLine(snap)}`);
1204
- } catch { /* advisory — never noisy on RPC trouble */ }
1205
- };
1206
- const pollSecs = Number(process.env.OPENZOO_BALANCE_POLL_SECS ?? 45);
1207
- if (pollSecs > 0) {
1208
- const timer = setInterval(refreshBalances, pollSecs * 1000);
1209
- timer.unref?.();
1210
- }
1211
1202
  // Which rails the zoo will actually settle right now, straight off a live
1212
1203
  // 402 — so nobody funds a lane the resource is not currently offering.
1213
1204
  try {
@@ -97,15 +97,12 @@ export function whopFundBlurb(solana, evm) {
97
97
  const url = whopBuyUrl();
98
98
  const base = String(evm || '').trim();
99
99
  return [
100
- `Hey — buy this: ${url}`,
101
- '',
102
- 'Copy-paste THIS Solana address into "what is your Solana address?" so it ties to your account:',
103
- addr,
104
- // OR FUND THE WALLET DIRECTLY. Every failed payment ends here: card via
105
- // Whop, or USDC to either address — those are the only three moves a
106
- // stuck caller has, so they are the whole message.
107
- '',
108
- `Or send USDC directly — Solana: ${addr}${base ? ` · Base: ${base}` : ''}`,
100
+ 'Funding options:',
101
+ `Card: ${url}`,
102
+ `At checkout, use this Solana wallet address to link your purchase: ${addr}`,
103
+ `Solana (USDC): ${addr}`,
104
+ ...(base ? [`Base (USDC): ${base}`] : []),
105
+ 'Your balance refreshes automatically once the payment is confirmed.',
109
106
  ].join('\n');
110
107
  }
111
108
 
@@ -151,11 +148,10 @@ export function settleFailCopy(q402) {
151
148
  const reason = gatewayReason(q402);
152
149
  const code = q402?.advice?.code || q402?.error?.code || '';
153
150
  const fund = isFundInstruction(reason, { code, advice: q402?.advice });
154
- const message = reason
155
- ? `openzoo payment did not settle: ${reason}`
156
- : 'openzoo payment did not settle';
157
- const upstreamish = /upstream|facilitator|internal(?: server)? error|settle(?:ment)? (?:failed|error)/i.test(reason) && !fund;
158
- return { message, status: upstreamish ? 502 : 402, fund, reason, code: String(code || '') };
151
+ const message = fund
152
+ ? 'Your available balance does not cover this request yet. If you just added funds, wait for confirmation and try again.'
153
+ : 'We couldn’t complete the payment for this request. Please try again. If you already added funds, you don’t need to add them again.';
154
+ return { message, status: fund ? 402 : 503, fund, reason, code: String(code || '') };
159
155
  }
160
156
 
161
157
  export async function withOnrampLink(text, dest) {
@@ -164,5 +160,5 @@ export async function withOnrampLink(text, dest) {
164
160
  const blurb = whopFundBlurb(dest?.solana, dest?.evm);
165
161
  if (!blurb) return body;
166
162
  if (/ties to your account/i.test(body) && body.includes(String(dest.solana))) return body;
167
- return `${blurb}\n\n${body}`;
163
+ return `${body}\n\n${blurb}`;
168
164
  }
package/lib/x402.js CHANGED
@@ -422,8 +422,9 @@ export async function tokenBalance(connection, owner, mintStr) {
422
422
  const ata = getAssociatedTokenAddressSync(new PublicKey(mintStr), owner, false, programId);
423
423
  const bal = await connection.getTokenAccountBalance(ata);
424
424
  return { raw: BigInt(bal.value.amount), ui: bal.value.uiAmount };
425
- } catch {
426
- return { raw: 0n, ui: 0 };
425
+ } catch (error) {
426
+ if (/could not find account|account.*(?:does not exist|not found)/i.test(error.message)) return { raw: 0n, ui: 0 };
427
+ throw error;
427
428
  }
428
429
  }
429
430
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "openzoo",
3
- "version": "0.51.6",
4
- "description": "Local x402-paying proxy + MCP server for openzoo.fun \u2014 point any OpenAI-compatible harness (Cursor, Claude Code, aider, SDKs) at localhost and it pays per call from a local burner wallet. Solana and Base rails live; Robinhood experimental.",
3
+ "version": "0.51.8",
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",
7
7
  "bin": {