openzoo 0.51.6 → 0.51.7

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,185 @@
1
+ /** Read-only savings overlay for the OpenZoo ChatGPT profile. */
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { spawn } from 'node:child_process';
5
+ import { fileURLToPath } from 'node:url';
6
+ import { cdpSession, listCdpPages } from './ozSpendChip.js';
7
+
8
+ export function chatGptSpendState(info) {
9
+ if (info?.youAreTalkingTo !== 'openzoo proxy') throw new Error('not an OpenZoo proxy');
10
+ const amount = value => typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : null;
11
+ const spent = amount(info.spendUsd), would = amount(info.directUsd);
12
+ const comparable = spent !== null && would !== null && (would > 0 || spent === 0);
13
+ // Do not use savedUsd: older proxies clamp negative savings to zero.
14
+ const saved = comparable ? would - spent : null;
15
+ return { spent, would: comparable ? would : null, saved,
16
+ pct: comparable && would > 0 ? 100 * saved / would : null,
17
+ scope: 'All-time · shared OpenZoo proxy', updatedAt: Date.now(), online: true };
18
+ }
19
+
20
+ /** Self-contained renderer. Only owns its shadow-root overlay, never chat content. */
21
+ export function renderChatGptSpend(state) {
22
+ let host = document.getElementById('openzoo-chatgpt-spend');
23
+ if (!document.body) return false;
24
+ if (!host) {
25
+ host = document.createElement('div');
26
+ host.id = 'openzoo-chatgpt-spend';
27
+ host.style.cssText = 'position:fixed;right:20px;bottom:90px;z-index:2147483646;max-width:calc(100vw - 24px);-webkit-app-region:no-drag';
28
+ const root = host.attachShadow({ mode: 'open' });
29
+ root.innerHTML = `<style>
30
+ :host{font:12px/1.5 system-ui,sans-serif;color:#fafafa;color-scheme:dark}
31
+ details{max-width:min(470px,calc(100vw - 24px))}
32
+ 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}
33
+ summary::-webkit-details-marker{display:none}summary:focus-visible{outline:2px solid white;outline-offset:3px}
34
+ section{margin-top:8px;padding:12px 14px;border:1px solid #ffffff24;border-radius:14px;background:#18181bef;box-shadow:0 4px 18px #0005}
35
+ dl{display:grid;grid-template-columns:1fr auto;gap:5px 24px;margin:10px 0}dd{margin:0;font-variant-numeric:tabular-nums}
36
+ 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}
37
+ </style><details><summary role="button" aria-expanded="false" aria-label="OpenZoo costs and savings"></summary><section>
38
+ <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>
39
+ <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>
40
+ <button type="button">Reset position</button></section></details>`;
41
+ const summary = root.querySelector('summary');
42
+ let drag = null, moved = false;
43
+ const place = (left, top) => {
44
+ const r = host.getBoundingClientRect();
45
+ host.style.left = Math.max(8, Math.min(left, innerWidth - r.width - 8)) + 'px';
46
+ host.style.top = Math.max(8, Math.min(top, innerHeight - r.height - 8)) + 'px';
47
+ host.style.right = host.style.bottom = 'auto';
48
+ };
49
+ document.body.appendChild(host);
50
+ 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 */ }
51
+ summary.addEventListener('pointerdown', e => {
52
+ if (e.button !== 0) return;
53
+ const r = host.getBoundingClientRect();
54
+ drag = { x: e.clientX, y: e.clientY, left: r.left, top: r.top }; moved = false;
55
+ summary.setPointerCapture(e.pointerId);
56
+ });
57
+ summary.addEventListener('pointermove', e => {
58
+ if (!drag) return;
59
+ const dx = e.clientX - drag.x, dy = e.clientY - drag.y;
60
+ if (Math.abs(dx) + Math.abs(dy) > 4) moved = true;
61
+ if (moved) place(drag.left + dx, drag.top + dy);
62
+ });
63
+ summary.addEventListener('pointerup', () => {
64
+ if (drag && moved) { const r = host.getBoundingClientRect(); try { localStorage.setItem('openzoo-chatgpt-spend-position', JSON.stringify({ x: r.left, y: r.top })); } catch {} }
65
+ drag = null;
66
+ });
67
+ summary.addEventListener('pointercancel', () => { drag = null; });
68
+ summary.addEventListener('click', e => { if (moved) { e.preventDefault(); moved = false; } });
69
+ root.querySelector('button').addEventListener('click', () => {
70
+ try { localStorage.removeItem('openzoo-chatgpt-spend-position'); } catch {}
71
+ host.style.left = host.style.top = 'auto'; host.style.right = '20px'; host.style.bottom = '90px';
72
+ });
73
+ 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); } });
74
+ addEventListener('resize', () => { if (host.isConnected && host.style.left !== 'auto' && host.style.left) { const r = host.getBoundingClientRect(); place(r.left, r.top); } });
75
+ // A dead bridge must not leave apparently live numbers indefinitely.
76
+ const timer = setInterval(() => {
77
+ if (!host.isConnected) { clearInterval(timer); return; }
78
+ if (Date.now() - (host.__ozUpdate || 0) > 12000) {
79
+ root.querySelector('[data-status]').textContent = 'Disconnected · last known totals';
80
+ root.querySelector('summary').textContent = 'OpenZoo · disconnected';
81
+ }
82
+ }, 3000);
83
+ }
84
+ const root = host.shadowRoot;
85
+ if (!root) return false;
86
+ const usd = n => n === null || !Number.isFinite(n) ? '—' : '$' + Math.abs(n).toFixed(Math.abs(n) < 0.01 ? 4 : 2);
87
+ const more = state.saved !== null && state.saved < 0;
88
+ const savings = state.saved === null ? 'Savings —' : `${more ? 'Extra cost' : 'Saved'} ${usd(state.saved)}`;
89
+ root.querySelector('summary').textContent = state.online ? `OpenZoo · ${usd(state.spent)} spent · ${savings}` : 'OpenZoo · disconnected';
90
+ root.querySelector('[data-spent]').textContent = usd(state.spent);
91
+ root.querySelector('[data-would]').textContent = usd(state.would);
92
+ root.querySelector('[data-saving-label]').textContent = more ? 'Extra cost' : 'Saved';
93
+ root.querySelector('[data-saved]').textContent = usd(state.saved) + (state.pct === null ? '' : ` (${Math.abs(state.pct).toFixed(1)}%)`);
94
+ root.querySelector('[data-scope]').textContent = state.scope;
95
+ root.querySelector('[data-status]').textContent = state.online ? 'Updated ' + new Date(state.updatedAt).toLocaleTimeString() : 'Proxy unavailable · last known totals';
96
+ host.__ozUpdate = Date.now();
97
+ return true;
98
+ }
99
+
100
+ export function chatGptSpendSource(state) {
101
+ return `(${renderChatGptSpend.toString()})(${JSON.stringify(state).replace(/</g, '\\u003c')})`;
102
+ }
103
+
104
+ export function readChatGptDebugPort(profile) {
105
+ try {
106
+ const n = Number(fs.readFileSync(path.join(profile, 'DevToolsActivePort'), 'utf8').split(/\r?\n/)[0]);
107
+ return Number.isInteger(n) && n > 0 && n < 65536 ? n : null;
108
+ } catch { return null; }
109
+ }
110
+
111
+ export function isChatGptRenderer(target, port) {
112
+ try {
113
+ const ws = new URL(target.webSocketDebuggerUrl);
114
+ if (ws.protocol !== 'ws:' || ws.hostname !== '127.0.0.1' && ws.hostname !== 'localhost' || Number(ws.port) !== port) return false;
115
+ const url = new URL(target.url);
116
+ return target.type === 'page' && (url.protocol === 'file:' && /\/index\.html$/.test(url.pathname) || url.protocol === 'app:' && /codex|chatgpt/.test(url.hostname));
117
+ } catch { return false; }
118
+ }
119
+
120
+ /** Host-side polling avoids changing the signed app or relaxing renderer CSP. */
121
+ export async function watchChatGptSpend({ profile, proxyPort, fetchImpl = fetch, connect = cdpSession,
122
+ sleep = ms => new Promise(r => setTimeout(r, ms)), stopped = () => false, log = console.error } = {}) {
123
+ const sessions = new Map();
124
+ let misses = 0;
125
+ let state = { spent: null, would: null, saved: null, pct: null, scope: 'All-time · shared OpenZoo proxy', online: false, updatedAt: 0 };
126
+ try {
127
+ while (!stopped() && misses < 20) {
128
+ const port = readChatGptDebugPort(profile);
129
+ const pages = port ? (await listCdpPages(port, fetchImpl)).filter(t => isChatGptRenderer(t, port)) : [];
130
+ misses = pages.length ? 0 : misses + 1;
131
+ const live = new Set(pages.map(t => t.webSocketDebuggerUrl));
132
+ for (const [id, session] of sessions) if (!live.has(id)) { session.close(); sessions.delete(id); }
133
+ try {
134
+ const r = await fetchImpl(`http://127.0.0.1:${proxyPort}/v1/info`, { signal: AbortSignal.timeout(2000) });
135
+ if (!r.ok) throw new Error(`proxy HTTP ${r.status}`);
136
+ state = chatGptSpendState(await r.json());
137
+ } catch { state = { ...state, online: false }; }
138
+ for (const page of pages) {
139
+ const id = page.webSocketDebuggerUrl;
140
+ try {
141
+ if (!sessions.has(id)) sessions.set(id, await connect(id, { timeoutMs: 2500, sendOrigin: false }));
142
+ const result = await sessions.get(id).send('Runtime.evaluate', { expression: chatGptSpendSource(state), returnByValue: true });
143
+ if (result.exceptionDetails) throw new Error('savings pill renderer evaluation failed');
144
+ } catch (e) { sessions.get(id)?.close(); sessions.delete(id); log(`openzoo: savings pill: ${e.message}`); }
145
+ }
146
+ await sleep(3000);
147
+ }
148
+ if (misses >= 20) log('openzoo: savings pill stopped: no ChatGPT renderer found for 60s');
149
+ } finally { for (const session of sessions.values()) session.close(); }
150
+ }
151
+
152
+ export async function startChatGptSpend({ profile, proxyPort, spawnImpl = spawn } = {}) {
153
+ fs.mkdirSync(profile, { recursive: true });
154
+ const fd = fs.openSync(path.join(profile, 'openzoo-savings.log'), 'a', 0o600);
155
+ try {
156
+ const child = spawnImpl(process.execPath, [fileURLToPath(import.meta.url), '--watch', profile, String(proxyPort)], { detached: true, stdio: ['ignore', fd, fd] });
157
+ await new Promise((resolve, reject) => { child.once('spawn', resolve); child.once('error', reject); });
158
+ child.unref();
159
+ } finally { fs.closeSync(fd); }
160
+ }
161
+
162
+ /** One watcher per profile, including concurrent launcher invocations. */
163
+ export function claimSpendWatcher(profile) {
164
+ const lock = path.join(profile, 'openzoo-savings.lock');
165
+ try {
166
+ const pid = Number(fs.readFileSync(lock, 'utf8'));
167
+ if (Number.isInteger(pid) && pid > 0) {
168
+ try { process.kill(pid, 0); return null; } catch (e) { if (e.code !== 'ESRCH') return null; }
169
+ } else if (Date.now() - fs.statSync(lock).mtimeMs < 60000) return null;
170
+ fs.unlinkSync(lock);
171
+ } catch (e) { if (e.code !== 'ENOENT') throw e; }
172
+ let fd;
173
+ try { fd = fs.openSync(lock, 'wx', 0o600); } catch (e) { if (e.code === 'EEXIST') return null; throw e; }
174
+ fs.writeFileSync(fd, String(process.pid)); fs.closeSync(fd);
175
+ return () => { try { if (fs.readFileSync(lock, 'utf8') === String(process.pid)) fs.unlinkSync(lock); } catch {} };
176
+ }
177
+
178
+ if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url) && process.argv[2] === '--watch') {
179
+ const release = claimSpendWatcher(process.argv[3]);
180
+ if (release) {
181
+ process.once('exit', release);
182
+ watchChatGptSpend({ profile: process.argv[3], proxyPort: Number(process.argv[4]) })
183
+ .catch(e => { console.error(e.message); process.exitCode = 1; }).finally(release);
184
+ }
185
+ }
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
 
@@ -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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openzoo",
3
- "version": "0.51.6",
3
+ "version": "0.51.7",
4
4
  "description": "Local x402-paying proxy + MCP server for openzoo.fun \u2014 point any OpenAI-compatible harness (Cursor, Claude Code, aider, SDKs) at localhost and it pays per call from a local burner wallet. Solana and Base rails live; Robinhood experimental.",
5
5
  "license": "MIT",
6
6
  "type": "module",