openzoo 0.51.5 → 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 +14 -1
- package/bin/openzoo.js +23 -9
- package/lib/chatgpt-install.js +106 -0
- package/lib/chatgpt-spend.js +185 -0
- package/lib/chatgpt.js +118 -91
- package/lib/modelcommand.js +443 -0
- package/lib/ozSpendChip.js +2 -2
- package/lib/proxy.js +39 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -25,7 +25,20 @@ paid $0.002137 (9.5× cheaper than direct) · rail solana · tx 5Kd…
|
|
|
25
25
|
|
|
26
26
|
**Claude Code / grokui Auto** — `openzoo claude` sets the Anthropic API key + base URL to the local OpenZoo proxy (x402 pay-per-call). You do **not** need to authenticate Claude first. grokui orange Auto is this harness, not a `RUN:` text parser. PATH `~/.local/bin` is required on Mac so `claude` is found.
|
|
27
27
|
|
|
28
|
-
**ChatGPT desktop (official
|
|
28
|
+
**ChatGPT desktop (official macOS/Windows/Linux app)** — `openzoo chatgpt` configures the zoo, reuses an installed Codex-enabled ChatGPT app or downloads and installs the current official build for your platform/architecture, starts a background proxy, and launches the app. No TryOmarchy dependency. Choose **sign in with API key** (`sk-openzoo`) if prompted. The Codex side pays x402 per turn from your OpenZoo wallet; ordinary ChatGPT chats still use chatgpt.com.
|
|
29
|
+
|
|
30
|
+
- **macOS (Apple Silicon / Intel):** installs the official DMG into `~/Applications/ChatGPT.app`, verifies its signature, then launches through macOS LaunchServices. Requires a supported macOS version (currently 14+).
|
|
31
|
+
- **Windows (ARM64 / x64):** installs the current official Store-signed MSIX using PowerShell `Add-AppxPackage`, then discovers its executable from the installed package manifest. Windows app-installation policies and package prerequisites still apply.
|
|
32
|
+
- **Linux (ARM64 / x64):** downloads the official `.deb` and unpacks it under `~/.openzoo/apps/chatgpt`, without sudo or dpkg. Requires `bsdtar` or `ar`, plus `tar` and the desktop runtime libraries required by ChatGPT. Ubuntu/Debian/Fedora are upstream-supported; other distributions depend on compatible libraries. `--deb <file>` uses a local package when no app is installed.
|
|
33
|
+
|
|
34
|
+
Configuration defaults to `~/.openzoo/chatgpt/config.toml`, with a separate desktop profile. `CODEX_HOME` overrides that location; `--config /path/to/config.toml` also sets the home used at launch. Unrelated config tables are retained, and the first existing config is saved as `config.toml.openzoo-backup`. Installed apps are reused, not upgraded by this command. Use the app's updater for later updates.
|
|
35
|
+
|
|
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
|
+
|
|
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
|
+
|
|
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).
|
|
41
|
+
|
|
29
42
|
|
|
30
43
|
**Agent of Empires** (tmux session manager for coding agents, [agent-of-empires](https://github.com/agent-of-empires/agent-of-empires)) — `openzoo aoe` registers an `openzoo` agent in aoe's `config.toml` (`[session.custom_agents] openzoo = "openzoo claude"`, detected as `claude` so status hooks, resume and fork all work), starts a background proxy if none is listening (no public tunnel unless `--tunnel`), then runs `aoe`. `openzoo aoe add . -l` creates and launches one session in the current directory; in the TUI pick **openzoo** from the agent picker. `Alt+z` opens a tool session with the wallet balance and the live receipts log. Terminal view only: aoe's structured view runs `claude-agent-acp` itself, which bills Anthropic, not the zoo. `--override-claude` additionally makes aoe's built-in `claude` agent launch through openzoo; `--no-launch` writes config only.
|
|
31
44
|
|
package/bin/openzoo.js
CHANGED
|
@@ -146,16 +146,19 @@ usage:
|
|
|
146
146
|
voice login / voice watch PREHOOK your own outgoing Telegram
|
|
147
147
|
messages: type raw, the watcher revises in place (userbot;
|
|
148
148
|
"." prefix sends raw). X has no edit API — no equivalent.
|
|
149
|
-
npx openzoo chatgpt
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
--model <id> (
|
|
149
|
+
npx openzoo chatgpt Configure, install if missing, and launch official ChatGPT
|
|
150
|
+
on macOS / Windows / Linux (x64 / ARM64), no TryOmarchy.
|
|
151
|
+
Downloads current official DMG / MSIX / .deb as needed.
|
|
152
|
+
Uses ~/.openzoo/chatgpt/config.toml (or CODEX_HOME), keeps
|
|
153
|
+
other tables and the original backup, starts a local proxy.
|
|
154
|
+
Codex / agent mode pays x402; plain chat uses chatgpt.com.
|
|
155
|
+
Choose "sign in with API key" (sk-openzoo) if prompted.
|
|
156
|
+
--deb <file> (Linux) --config /path/to/config.toml
|
|
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
|
|
161
|
+
-- <app flags>
|
|
159
162
|
npx openzoo openclaw write the zoo into ~/.openclaw/openclaw.json as a model
|
|
160
163
|
provider WITH REAL PRICES (OpenClaw's own custom-provider
|
|
161
164
|
path hard-codes $0.00 and ignores /v1/models pricing)
|
|
@@ -180,6 +183,11 @@ usage:
|
|
|
180
183
|
npx openzoo balance wallet balance on every rail — Solana (USDC/TOKEN/SOL),
|
|
181
184
|
Base (USDC/ETH), Robinhood Chain (USDG/memecoins/ETH)
|
|
182
185
|
npx openzoo address print both funding addresses (Solana + EVM)
|
|
186
|
+
npx openzoo model [id] pin the model every chat uses, whatever the app sends
|
|
187
|
+
(no id = show it · reset|off = clear). Same override the
|
|
188
|
+
chat command \`/model <id>\` sets from inside ChatGPT,
|
|
189
|
+
Cursor or Claude Code — a running proxy picks it up
|
|
190
|
+
without a restart.
|
|
183
191
|
npx openzoo help this text
|
|
184
192
|
|
|
185
193
|
point any OpenAI-compatible harness at:
|
|
@@ -420,6 +428,12 @@ async function main() {
|
|
|
420
428
|
case 'address':
|
|
421
429
|
(await import('../lib/info.js')).printAddress();
|
|
422
430
|
break;
|
|
431
|
+
// Same override file the in-chat `/model` command writes. Two doors on one
|
|
432
|
+
// switch: a harness with no model picker (ChatGPT/Codex) uses the chat
|
|
433
|
+
// command, everyone else can just type it here.
|
|
434
|
+
case 'model':
|
|
435
|
+
await (await import('../lib/modelcommand.js')).cliModel(process.argv[3]);
|
|
436
|
+
break;
|
|
423
437
|
case 'help':
|
|
424
438
|
case '--help':
|
|
425
439
|
case '-h':
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/** Official current desktop installers; no third-party distribution service. */
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { spawn } from 'node:child_process';
|
|
6
|
+
import { Readable } from 'node:stream';
|
|
7
|
+
import { pipeline } from 'node:stream/promises';
|
|
8
|
+
|
|
9
|
+
const CDN = 'https://persistent.oaistatic.com/codex-app-prod';
|
|
10
|
+
export function installerAsset(platform = process.platform, arch = process.arch) {
|
|
11
|
+
if (!['x64', 'arm64'].includes(arch)) throw new Error(`ChatGPT has no installer for ${platform}/${arch}; see https://chatgpt.com/download/`);
|
|
12
|
+
if (platform === 'darwin') return { url: `${CDN}/${arch === 'x64' ? 'ChatGPT-latest-x64.dmg' : 'ChatGPT.dmg'}`, name: 'ChatGPT.dmg' };
|
|
13
|
+
if (platform === 'win32') return { url: `${CDN}/ChatGPT-${arch}.msix`, name: 'ChatGPT.msix' };
|
|
14
|
+
if (platform === 'linux') {
|
|
15
|
+
const name = `chatgpt_${arch === 'x64' ? 'amd64' : 'arm64'}.deb`;
|
|
16
|
+
return { url: `${CDN}/linux/deb/latest/${name}`, name };
|
|
17
|
+
}
|
|
18
|
+
throw new Error(`ChatGPT desktop is not available for ${platform}; see https://chatgpt.com/download/`);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function runCommand(command, args, options = {}) {
|
|
22
|
+
return new Promise((resolve, reject) => {
|
|
23
|
+
const child = spawn(command, args, { stdio: ['ignore', 'pipe', 'pipe'], ...options });
|
|
24
|
+
let stdout = '', stderr = '';
|
|
25
|
+
child.stdout?.on('data', b => { stdout = (stdout + b).slice(-1024 * 1024); });
|
|
26
|
+
child.stderr?.on('data', b => { stderr = (stderr + b).slice(-8192); });
|
|
27
|
+
child.once('error', reject);
|
|
28
|
+
child.once('close', code => code === 0 ? resolve(stdout.trim()) : reject(new Error(`${command} exited ${code}: ${stderr.trim()}`)));
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export async function downloadInstaller(url, file, fetchImpl = fetch) {
|
|
33
|
+
const partial = `${file}.part`;
|
|
34
|
+
try {
|
|
35
|
+
const response = await fetchImpl(url, { signal: AbortSignal.timeout(15 * 60_000) });
|
|
36
|
+
if (!response.ok || !response.body) throw new Error(`download failed (${response.status}): ${url}`);
|
|
37
|
+
if (/text\/html/.test(response.headers.get('content-type') || '')) throw new Error(`installer URL returned HTML: ${url}`);
|
|
38
|
+
await pipeline(Readable.fromWeb(response.body), fs.createWriteStream(partial, { flags: 'wx' }));
|
|
39
|
+
if (!fs.statSync(partial).size) throw new Error('downloaded installer is empty');
|
|
40
|
+
fs.renameSync(partial, file);
|
|
41
|
+
} finally {
|
|
42
|
+
fs.rmSync(partial, { force: true });
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Query the installed package manifest instead of guessing a versioned WindowsApps path.
|
|
47
|
+
// The bundled Codex engine distinguishes the combined app from ChatGPT Classic.
|
|
48
|
+
export async function windowsChatGptApp(run = runCommand) {
|
|
49
|
+
const script = `$ErrorActionPreference = 'Stop'
|
|
50
|
+
Get-AppxPackage | Where-Object { $_.Name -match 'OpenAI|ChatGPT|Codex' } | ForEach-Object {
|
|
51
|
+
$pkg = $_
|
|
52
|
+
$manifest = Get-AppxPackageManifest -Package $pkg.PackageFullName
|
|
53
|
+
foreach ($entry in $manifest.Package.Applications.Application) {
|
|
54
|
+
if ($entry.Executable) {
|
|
55
|
+
$exe = Join-Path $pkg.InstallLocation $entry.Executable
|
|
56
|
+
if ((Test-Path -LiteralPath $exe) -and (Test-Path -LiteralPath (Join-Path (Split-Path $exe) 'resources/codex.exe'))) { Write-Output $exe }
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}`;
|
|
60
|
+
const output = await run('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', script]);
|
|
61
|
+
return output.split(/\r?\n/).map(s => s.trim()).find(Boolean) || null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export async function installChatGpt({ platform = process.platform, arch = process.arch, home = os.homedir(),
|
|
65
|
+
run = runCommand, download = downloadInstaller, unpack, findWindows = windowsChatGptApp, log = console.log } = {}) {
|
|
66
|
+
const asset = installerAsset(platform, arch);
|
|
67
|
+
const scratch = fs.mkdtempSync(path.join(os.tmpdir(), 'openzoo-chatgpt-'));
|
|
68
|
+
const file = path.join(scratch, asset.name);
|
|
69
|
+
try {
|
|
70
|
+
log(`downloading current ChatGPT for ${platform}/${arch} from ${asset.url}`);
|
|
71
|
+
await download(asset.url, file);
|
|
72
|
+
if (platform === 'linux') return unpack(file, path.join(home, '.openzoo', 'apps', 'chatgpt'));
|
|
73
|
+
if (platform === 'win32') {
|
|
74
|
+
// The path is passed through the environment, never interpolated into PowerShell code.
|
|
75
|
+
await run('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command',
|
|
76
|
+
"$ErrorActionPreference = 'Stop'; Add-AppxPackage -Path $env:OPENZOO_CHATGPT_INSTALLER"],
|
|
77
|
+
{ env: { ...process.env, OPENZOO_CHATGPT_INSTALLER: file } });
|
|
78
|
+
const app = await findWindows(run);
|
|
79
|
+
if (!app) throw new Error('ChatGPT MSIX installed but its desktop executable was not found');
|
|
80
|
+
return app;
|
|
81
|
+
}
|
|
82
|
+
const mount = path.join(scratch, 'mount');
|
|
83
|
+
fs.mkdirSync(mount);
|
|
84
|
+
let mounted = false;
|
|
85
|
+
try {
|
|
86
|
+
await run('hdiutil', ['attach', '-nobrowse', '-readonly', '-mountpoint', mount, file]);
|
|
87
|
+
mounted = true;
|
|
88
|
+
const source = path.join(mount, 'ChatGPT.app');
|
|
89
|
+
// Let macOS validate the vendor signature before copying executable code.
|
|
90
|
+
await run('codesign', ['--verify', '--deep', '--strict', source]);
|
|
91
|
+
if (!fs.existsSync(path.join(source, 'Contents', 'Resources', 'codex'))) throw new Error('installer contains ChatGPT Classic, not the Codex-enabled desktop app');
|
|
92
|
+
const applications = path.join(home, 'Applications');
|
|
93
|
+
fs.mkdirSync(applications, { recursive: true });
|
|
94
|
+
const destination = path.join(applications, 'ChatGPT.app');
|
|
95
|
+
if (fs.existsSync(destination)) throw new Error(`${destination} already exists but is not usable; move it aside or set OPENZOO_CHATGPT_BIN`);
|
|
96
|
+
const staged = path.join(applications, `.openzoo-chatgpt-${process.pid}-${Date.now()}.app`);
|
|
97
|
+
try {
|
|
98
|
+
await run('ditto', [source, staged]);
|
|
99
|
+
fs.renameSync(staged, destination);
|
|
100
|
+
} finally { fs.rmSync(staged, { recursive: true, force: true }); }
|
|
101
|
+
return path.join(destination, 'Contents', 'MacOS', 'ChatGPT');
|
|
102
|
+
} finally {
|
|
103
|
+
if (mounted) await run('hdiutil', ['detach', mount]);
|
|
104
|
+
}
|
|
105
|
+
} finally { fs.rmSync(scratch, { recursive: true, force: true }); }
|
|
106
|
+
}
|
|
@@ -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
|
@@ -1,38 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* `npx openzoo chatgpt` — OpenAI's ChatGPT desktop app on the zoo.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* its `[model_providers.<id>]` table is the supported way to point it at any
|
|
9
|
-
* OpenAI-shaped server. MEASURED 2026-09-09 on the .deb's `codex` binary:
|
|
10
|
-
* the strings `model_providers`, `env_key`, `requires_openai_auth`,
|
|
11
|
-
* `experimental_bearer_token`, `responses` and `https://api.openai.com/v1`
|
|
12
|
-
* (as a default, next to `ollama` and `amazon-bedrock`) are all there, and
|
|
13
|
-
* a provider block pointed at :8402 answered in the app.
|
|
14
|
-
*
|
|
15
|
-
* What this does and does not redirect, so nobody is surprised:
|
|
16
|
-
* - the Codex / agent side of the app ("sign in with API key") → the zoo,
|
|
17
|
-
* paid per turn by x402 through the local proxy;
|
|
18
|
-
* - the plain ChatGPT conversation tab → still chatgpt.com. It talks to
|
|
19
|
-
* `chatgpt_base_url = "https://chatgpt.com/backend-api/"`, a private
|
|
20
|
-
* backend that is not API-shaped, so there is nothing to point at us.
|
|
21
|
-
* - the two auth modes are exclusive: the binary logs "ChatGPT login is
|
|
22
|
-
* required, but an API key is currently being used. Logging out." Pick
|
|
23
|
-
* API-key mode for the session that should pay x402.
|
|
24
|
-
*
|
|
25
|
-
* One command, end to end: write the provider into config.toml (backing up
|
|
26
|
-
* the old file, keeping every other table), make sure a proxy is listening,
|
|
27
|
-
* find the app — or unpack the downloaded .deb on a distro that cannot
|
|
28
|
-
* install it, which is how this was first done on Arch — and launch it with
|
|
29
|
-
* the env var the provider's `env_key` names.
|
|
4
|
+
* Configures an isolated Codex home, installs the current official app when
|
|
5
|
+
* missing, starts the local proxy and launches the app with that configuration.
|
|
6
|
+
* Custom provider routing applies to Codex; ordinary ChatGPT chats still use
|
|
7
|
+
* chatgpt.com. No TryOmarchy or third-party app distribution is involved.
|
|
30
8
|
*/
|
|
31
9
|
import { spawn, spawnSync } from 'node:child_process';
|
|
32
10
|
import fs from 'node:fs';
|
|
33
11
|
import os from 'node:os';
|
|
34
12
|
import path from 'node:path';
|
|
35
13
|
import { config } from './config.js';
|
|
14
|
+
import { startChatGptSpend } from './chatgpt-spend.js';
|
|
15
|
+
import { installChatGpt, windowsChatGptApp, runCommand } from './chatgpt-install.js';
|
|
36
16
|
|
|
37
17
|
export const PROVIDER_ID = 'openzoo';
|
|
38
18
|
export const API_KEY_ENV = 'OPENZOO_API_KEY';
|
|
@@ -48,8 +28,15 @@ export function codexConfigPath(env = process.env, home = os.homedir()) {
|
|
|
48
28
|
}
|
|
49
29
|
|
|
50
30
|
export function defaultModel(env = process.env) {
|
|
31
|
+
// DEEPSEEK BY DEFAULT. The person running `openzoo chatgpt` has not chosen
|
|
32
|
+
// a model and mostly will not: the default is what they pay for on every
|
|
33
|
+
// turn until they type /model. It used to be claude-opus-5, the most
|
|
34
|
+
// expensive door in the catalog, so the "much less spend" pitch shipped
|
|
35
|
+
// with the priciest setting turned on. deepseek-v4-flash is a fraction of
|
|
36
|
+
// a cent per million tokens in, carries a 1M-token window, and is a bare
|
|
37
|
+
// id the doors serve directly. Anyone who wants more says /model.
|
|
51
38
|
// Bare ids are what the doors serve; the gateway resolves the rest.
|
|
52
|
-
return env.OPENZOO_DEFAULT_MODEL || '
|
|
39
|
+
return env.OPENZOO_DEFAULT_MODEL || 'deepseek-v4-flash';
|
|
53
40
|
}
|
|
54
41
|
|
|
55
42
|
/**
|
|
@@ -113,7 +100,7 @@ export function mergeCodexConfig(existing, { model, port, wireApi, bearer } = {}
|
|
|
113
100
|
});
|
|
114
101
|
const head = [
|
|
115
102
|
OUR_MARK,
|
|
116
|
-
`model =
|
|
103
|
+
`model = ${JSON.stringify(model || defaultModel())}`,
|
|
117
104
|
`model_provider = "${PROVIDER_ID}"`,
|
|
118
105
|
];
|
|
119
106
|
// trim blank padding so reruns do not accumulate empty lines
|
|
@@ -134,14 +121,15 @@ export function mergeCodexConfig(existing, { model, port, wireApi, bearer } = {}
|
|
|
134
121
|
|
|
135
122
|
/** Candidate app binaries, most specific first. Exported for tests. */
|
|
136
123
|
export function chatGptCandidates({ env = process.env, home = os.homedir(), platform = process.platform } = {}) {
|
|
124
|
+
const paths = platform === 'win32' ? path.win32 : path;
|
|
137
125
|
const out = [];
|
|
138
126
|
if (env.OPENZOO_CHATGPT_BIN) out.push(env.OPENZOO_CHATGPT_BIN);
|
|
139
127
|
if (platform === 'darwin') {
|
|
140
128
|
out.push('/Applications/ChatGPT.app/Contents/MacOS/ChatGPT');
|
|
141
129
|
out.push(path.join(home, 'Applications', 'ChatGPT.app', 'Contents', 'MacOS', 'ChatGPT'));
|
|
142
130
|
} else if (platform === 'win32') {
|
|
143
|
-
const local = env.LOCALAPPDATA ||
|
|
144
|
-
out.push(
|
|
131
|
+
const local = env.LOCALAPPDATA || paths.join(home, 'AppData', 'Local');
|
|
132
|
+
out.push(paths.join(local, 'Programs', 'ChatGPT', 'ChatGPT.exe'));
|
|
145
133
|
} else {
|
|
146
134
|
// the .deb's layout (apt install), then where THIS command unpacks it,
|
|
147
135
|
// then the by-hand unpack path the first Arch run used
|
|
@@ -150,15 +138,21 @@ export function chatGptCandidates({ env = process.env, home = os.homedir(), plat
|
|
|
150
138
|
out.push(path.join(unpackDir(home), 'usr', 'lib', 'chatgpt', 'ChatGPT'));
|
|
151
139
|
out.push(path.join(home, 'apps', 'chatgpt', 'usr', 'lib', 'chatgpt', 'ChatGPT'));
|
|
152
140
|
}
|
|
153
|
-
for (const dir of String(env.PATH || '').split(
|
|
154
|
-
out.push(
|
|
141
|
+
for (const dir of String(env.PATH || '').split(platform === 'win32' ? ';' : ':').filter(Boolean)) {
|
|
142
|
+
out.push(paths.join(dir, platform === 'win32' ? 'chatgpt.exe' : 'chatgpt'));
|
|
155
143
|
}
|
|
156
144
|
return out;
|
|
157
145
|
}
|
|
158
146
|
|
|
159
147
|
export function resolveChatGptApp(opts = {}) {
|
|
160
148
|
const exists = opts.exists || ((p) => { try { fs.accessSync(p, fs.constants.X_OK); return true; } catch { return false; } });
|
|
161
|
-
return chatGptCandidates(opts).find(
|
|
149
|
+
return chatGptCandidates(opts).find(p => {
|
|
150
|
+
if (!exists(p)) return false;
|
|
151
|
+
if ((opts.platform || process.platform) === 'darwin' && p.endsWith('/Contents/MacOS/ChatGPT')) {
|
|
152
|
+
return exists(path.resolve(path.dirname(p), '../Resources/codex'));
|
|
153
|
+
}
|
|
154
|
+
return true;
|
|
155
|
+
}) || null;
|
|
162
156
|
}
|
|
163
157
|
|
|
164
158
|
/** Where `--deb` (or an auto-found download) is unpacked. */
|
|
@@ -190,27 +184,35 @@ function have(bin) {
|
|
|
190
184
|
*/
|
|
191
185
|
export function unpackDeb(deb, dest = unpackDir()) {
|
|
192
186
|
if (!fs.existsSync(deb)) throw new Error(`no such file: ${deb}`);
|
|
193
|
-
fs.
|
|
194
|
-
fs.mkdirSync(dest, { recursive: true });
|
|
187
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
195
188
|
const outer = fs.mkdtempSync(path.join(os.tmpdir(), 'openzoo-deb-'));
|
|
189
|
+
const staged = fs.mkdtempSync(path.join(path.dirname(dest), '.chatgpt-unpack-'));
|
|
196
190
|
const run = (cmd, args, cwd) => {
|
|
197
191
|
const r = spawnSync(cmd, args, { cwd, stdio: ['ignore', 'ignore', 'pipe'] });
|
|
198
|
-
if (r.status !== 0) throw new Error(`${cmd} ${
|
|
192
|
+
if (r.status !== 0) throw new Error(`${cmd} failed: ${r.error?.message || String(r.stderr || '').trim().slice(0, 200)}`);
|
|
199
193
|
};
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
194
|
+
try {
|
|
195
|
+
if (have('bsdtar')) run('bsdtar', ['-xf', deb], outer);
|
|
196
|
+
else if (have('ar')) run('ar', ['x', deb], outer);
|
|
197
|
+
else throw new Error('need bsdtar (libarchive) or ar (binutils) to unpack a .deb');
|
|
198
|
+
const data = fs.readdirSync(outer).find((n) => /^data\.tar/.test(n));
|
|
199
|
+
if (!data) throw new Error('not a .deb: no data.tar inside');
|
|
200
|
+
run('tar', ['-xf', path.join(outer, data), '-C', staged]);
|
|
201
|
+
const relative = path.join('usr', 'lib', 'chatgpt', 'ChatGPT');
|
|
202
|
+
if (!fs.existsSync(path.join(staged, relative))) throw new Error('package does not contain the ChatGPT desktop executable');
|
|
203
|
+
fs.accessSync(path.join(staged, relative), fs.constants.X_OK);
|
|
204
|
+
// Do not erase a previous unpack until the replacement has been validated.
|
|
205
|
+
fs.rmSync(dest, { recursive: true, force: true });
|
|
206
|
+
fs.renameSync(staged, dest);
|
|
207
|
+
return path.join(dest, relative);
|
|
208
|
+
} finally {
|
|
209
|
+
fs.rmSync(outer, { recursive: true, force: true });
|
|
210
|
+
fs.rmSync(staged, { recursive: true, force: true });
|
|
211
|
+
}
|
|
210
212
|
}
|
|
211
213
|
|
|
212
214
|
async function ensureProxy({ tunnel }) {
|
|
213
|
-
const {
|
|
215
|
+
const { oursOn, packageVersion } = await import('./proxy.js');
|
|
214
216
|
if (await oursOn(config.port)) return { started: false };
|
|
215
217
|
// DETACHED, like `openzoo aoe`: the app outlives this command, so the
|
|
216
218
|
// proxy must too. No public tunnel unless asked — a detached proxy nobody
|
|
@@ -237,28 +239,30 @@ async function ensureProxy({ tunnel }) {
|
|
|
237
239
|
throw new Error(`proxy did not answer on :${config.port} within 60s — see ${logPath}`);
|
|
238
240
|
}
|
|
239
241
|
|
|
240
|
-
const INSTALL_HELP = `openzoo: ChatGPT desktop app not found.
|
|
241
|
-
download it from https://chatgpt.com/download (Linux: the .deb; macOS/Windows: the installer), then:
|
|
242
|
-
Debian/Ubuntu: sudo apt install ./chatgpt_amd64.deb
|
|
243
|
-
Fedora: sudo dnf install ./chatgpt*.rpm
|
|
244
|
-
Arch / other: npx openzoo chatgpt --deb ~/Downloads/chatgpt_amd64.deb (unpacked, no dpkg needed)
|
|
245
|
-
or point OPENZOO_CHATGPT_BIN at the binary.`;
|
|
246
|
-
|
|
247
242
|
/**
|
|
248
243
|
* `npx openzoo chatgpt [--model <id>] [--deb <file>] [--wire responses|chat]
|
|
249
244
|
* [--bearer] [--no-launch] [--no-proxy] [--tunnel] [--print]`
|
|
250
245
|
*/
|
|
251
|
-
export async function setupChatGpt(argv = []) {
|
|
246
|
+
export async function setupChatGpt(argv = [], dependencies = {}) {
|
|
247
|
+
const { platform = process.platform, home = os.homedir(), env: sourceEnv = process.env,
|
|
248
|
+
resolve = resolveChatGptApp, install = installChatGpt, proxy = ensureProxy,
|
|
249
|
+
launch = launchChatGpt, findWindows = windowsChatGptApp, overlay = startChatGptSpend } = dependencies;
|
|
252
250
|
const args = [...argv];
|
|
253
|
-
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: [] };
|
|
254
252
|
while (args.length) {
|
|
255
253
|
const a = args.shift();
|
|
256
|
-
if (a === '--
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
254
|
+
if (a === '--') { opt.rest.push(...args); break; }
|
|
255
|
+
const value = () => {
|
|
256
|
+
if (!args.length || args[0].startsWith('--') || !args[0].trim()) throw new Error(`${a} requires a value`);
|
|
257
|
+
return args.shift().trim();
|
|
258
|
+
};
|
|
259
|
+
if (a === '--model') opt.model = value();
|
|
260
|
+
else if (a === '--deb') opt.deb = value();
|
|
261
|
+
else if (a === '--wire') opt.wireApi = value();
|
|
262
|
+
else if (a === '--config') opt.configPath = value();
|
|
260
263
|
else if (a === '--bearer') opt.bearer = true;
|
|
261
264
|
else if (a === '--no-launch') opt.launch = false;
|
|
265
|
+
else if (a === '--no-pill') opt.pill = false;
|
|
262
266
|
else if (a === '--no-proxy') opt.proxy = false;
|
|
263
267
|
else if (a === '--tunnel') opt.tunnel = true;
|
|
264
268
|
else if (a === '--print') opt.print = true;
|
|
@@ -266,7 +270,8 @@ export async function setupChatGpt(argv = []) {
|
|
|
266
270
|
else opt.rest.push(a);
|
|
267
271
|
}
|
|
268
272
|
if (!['responses', 'chat'].includes(opt.wireApi)) throw new Error('--wire must be responses or chat');
|
|
269
|
-
|
|
273
|
+
if (opt.deb && platform !== 'linux') throw new Error('--deb is only supported on Linux');
|
|
274
|
+
const model = opt.model || defaultModel(sourceEnv);
|
|
270
275
|
const merge = { model, port: config.port, wireApi: opt.wireApi, bearer: opt.bearer };
|
|
271
276
|
|
|
272
277
|
if (opt.print) {
|
|
@@ -275,54 +280,76 @@ export async function setupChatGpt(argv = []) {
|
|
|
275
280
|
}
|
|
276
281
|
|
|
277
282
|
// 1. config.toml
|
|
278
|
-
const
|
|
283
|
+
const appHome = sourceEnv.CODEX_HOME || path.join(home, '.openzoo', 'chatgpt');
|
|
284
|
+
const file = path.resolve(opt.configPath || path.join(appHome, 'config.toml'));
|
|
285
|
+
if (path.basename(file) !== 'config.toml') throw new Error('--config must name config.toml (the filename Codex reads)');
|
|
279
286
|
let existing = '';
|
|
280
287
|
if (fs.existsSync(file)) {
|
|
281
288
|
existing = fs.readFileSync(file, 'utf8');
|
|
282
|
-
fs.copyFileSync(file, `${file}.openzoo-backup`);
|
|
283
289
|
} else {
|
|
284
290
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
285
291
|
}
|
|
286
292
|
const next = mergeCodexConfig(existing, merge);
|
|
287
|
-
if (next !== existing)
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
// 2. proxy
|
|
291
|
-
if (opt.proxy) {
|
|
292
|
-
const p = await ensureProxy({ tunnel: opt.tunnel });
|
|
293
|
-
console.log(p.started ? `proxy up on :${config.port} (background)` : `proxy already on :${config.port}`);
|
|
293
|
+
if (next !== existing) {
|
|
294
|
+
if (existing && !fs.existsSync(`${file}.openzoo-backup`)) fs.copyFileSync(file, `${file}.openzoo-backup`);
|
|
295
|
+
fs.writeFileSync(file, next);
|
|
294
296
|
}
|
|
297
|
+
console.log(`${next !== existing ? 'wrote' : 'kept'} ${file}: model_provider = "${PROVIDER_ID}" (http://localhost:${config.port}/v1, ${opt.wireApi}), model = "${model}"`);
|
|
295
298
|
|
|
296
299
|
if (!opt.launch) {
|
|
297
|
-
console.log(
|
|
300
|
+
console.log('config only; no download, installation, proxy or app launch.');
|
|
298
301
|
return { file, model, launched: false };
|
|
299
302
|
}
|
|
300
303
|
|
|
301
|
-
//
|
|
302
|
-
let app =
|
|
303
|
-
if (!app &&
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
}
|
|
312
|
-
if (!app) {
|
|
313
|
-
console.error(INSTALL_HELP);
|
|
314
|
-
process.exitCode = 1;
|
|
315
|
-
return { file, model, launched: false };
|
|
304
|
+
// Reuse an installation before attempting any download.
|
|
305
|
+
let app = resolve({ env: sourceEnv, home, platform });
|
|
306
|
+
if (!app && sourceEnv.OPENZOO_CHATGPT_BIN) throw new Error(`OPENZOO_CHATGPT_BIN is not an executable ChatGPT app: ${sourceEnv.OPENZOO_CHATGPT_BIN}`);
|
|
307
|
+
if (!app && platform === 'win32') app = await findWindows();
|
|
308
|
+
if (!app && platform === 'linux' && opt.deb) app = unpackDeb(path.resolve(opt.deb), unpackDir(home));
|
|
309
|
+
if (!app) app = await install({ platform, home, unpack: unpackDeb });
|
|
310
|
+
|
|
311
|
+
if (opt.proxy) {
|
|
312
|
+
const p = await proxy({ tunnel: opt.tunnel });
|
|
313
|
+
console.log(p.started ? `proxy up on :${config.port} (background)` : `proxy already on :${config.port}`);
|
|
316
314
|
}
|
|
317
315
|
|
|
318
316
|
// 4. launch, with the env var the provider's env_key names
|
|
319
|
-
const env = { ...
|
|
320
|
-
|
|
317
|
+
const env = { ...sourceEnv, [API_KEY_ENV]: sourceEnv[API_KEY_ENV] || API_KEY_VALUE,
|
|
318
|
+
CODEX_HOME: path.dirname(file),
|
|
319
|
+
CODEX_ELECTRON_USER_DATA_PATH: path.join(path.dirname(file), 'desktop'),
|
|
320
|
+
};
|
|
321
321
|
console.log(`launching ${app}`);
|
|
322
322
|
console.log(` Codex / agent mode → the zoo, paid per turn by x402. Choose "sign in with API key" (any key).`);
|
|
323
323
|
console.log(' the plain ChatGPT chat tab still talks to chatgpt.com — that half is not API-shaped.');
|
|
324
|
-
const
|
|
325
|
-
|
|
326
|
-
|
|
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
|
+
}
|
|
327
332
|
return { file, model, app, launched: true };
|
|
328
333
|
}
|
|
334
|
+
|
|
335
|
+
/** LaunchServices handles app bundles on macOS; detect early failures elsewhere. */
|
|
336
|
+
export async function launchChatGpt(app, args, { env, platform = process.platform, run = runCommand, spawnImpl = spawn } = {}) {
|
|
337
|
+
if (platform === 'darwin' && app.endsWith('/Contents/MacOS/ChatGPT')) {
|
|
338
|
+
await run('open', ['-n', '--env', `CODEX_HOME=${env.CODEX_HOME}`,
|
|
339
|
+
'--env', `CODEX_ELECTRON_USER_DATA_PATH=${env.CODEX_ELECTRON_USER_DATA_PATH}`,
|
|
340
|
+
'--env', `${API_KEY_ENV}=${env[API_KEY_ENV]}`,
|
|
341
|
+
app.slice(0, -'/Contents/MacOS/ChatGPT'.length), '--args',
|
|
342
|
+
`--user-data-dir=${env.CODEX_ELECTRON_USER_DATA_PATH}`, ...args], { env });
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
await new Promise((resolve, reject) => {
|
|
346
|
+
const child = spawnImpl(app, [`--user-data-dir=${env.CODEX_ELECTRON_USER_DATA_PATH}`, ...args], { stdio: 'ignore', env, detached: true });
|
|
347
|
+
const timer = setTimeout(() => { child.unref(); resolve(); }, 1500);
|
|
348
|
+
child.once('error', e => { clearTimeout(timer); reject(e); });
|
|
349
|
+
child.once('exit', (code, signal) => {
|
|
350
|
+
clearTimeout(timer);
|
|
351
|
+
if (code === 0) resolve(); // an existing instance accepted the launch
|
|
352
|
+
else reject(new Error(`ChatGPT exited during launch (${signal || code})`));
|
|
353
|
+
});
|
|
354
|
+
});
|
|
355
|
+
}
|
|
@@ -0,0 +1,443 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, mkdirSync, statSync, unlinkSync } from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { config } from './config.js';
|
|
5
|
+
import { quoteableRows } from './models.js';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* `/model <name>` AS A CHAT COMMAND.
|
|
9
|
+
*
|
|
10
|
+
* THE FAILURE THIS EXISTS FOR. A user running ChatGPT desktop (Codex side,
|
|
11
|
+
* pointed at this proxy) typed `/model fable-5` into the chat box expecting the
|
|
12
|
+
* app to switch models. The app has NO such command — it forwarded the line to
|
|
13
|
+
* the model as an ordinary question. The model then spent six minutes and real
|
|
14
|
+
* money shelling around the gateway trying to work out what the user meant,
|
|
15
|
+
* and the model never changed. Codex/ChatGPT gives no model picker for a
|
|
16
|
+
* custom provider, so without this the only ways to switch are editing
|
|
17
|
+
* ~/.codex/config.toml and restarting the app.
|
|
18
|
+
*
|
|
19
|
+
* So the PROXY answers the command: it never reaches the wire, nothing is
|
|
20
|
+
* paid, and the override is remembered on disk so it survives a proxy restart.
|
|
21
|
+
* Everything here is a pure function except the four file helpers, so the
|
|
22
|
+
* tests can drive it without a socket.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Normalised form for id comparison. MUST mirror the gateway's own matcher
|
|
27
|
+
* (x402-tokens src/modelmatch.ts) — a name the proxy accepts and the gateway
|
|
28
|
+
* then rejects is worse than no command at all, because the user has already
|
|
29
|
+
* been told "switched".
|
|
30
|
+
*
|
|
31
|
+
* Drop the vendor prefix, lowercase, and treat `.`/`_`/space as `-`, because
|
|
32
|
+
* the same model is spelled `claude-fable-5.1`, `claude_fable_5_1` and
|
|
33
|
+
* `anthropic/claude-fable-5-1` depending on who is printing it.
|
|
34
|
+
*/
|
|
35
|
+
export function normaliseModelId(s) {
|
|
36
|
+
const raw = String(s ?? '');
|
|
37
|
+
const bare = raw.slice(raw.lastIndexOf('/') + 1);
|
|
38
|
+
return bare
|
|
39
|
+
.toLowerCase()
|
|
40
|
+
.replace(/[._\s]+/g, '-')
|
|
41
|
+
.replace(/-+/g, '-')
|
|
42
|
+
.replace(/^-|-$/g, '');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Shortest normalised id first, then lexical on the REAL id — a total order,
|
|
46
|
+
* so the same typed name always lands on the same model. `claude-fable-5` and
|
|
47
|
+
* `venice/claude-fable-5` normalise identically; without the second key the
|
|
48
|
+
* winner would depend on catalog order, which changes hourly. */
|
|
49
|
+
function bestOf(cands) {
|
|
50
|
+
return cands.sort((a, b) => (
|
|
51
|
+
a.norm.length - b.norm.length || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)
|
|
52
|
+
))[0]?.id ?? null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Resolve a typed name against the gateway catalog. Exact normalised match,
|
|
57
|
+
* else a candidate ENDING in `-<want>` (so `fable-5` finds `claude-fable-5`),
|
|
58
|
+
* else a candidate merely containing it. Returns null when nothing fits.
|
|
59
|
+
*
|
|
60
|
+
* Two-character inputs are refused outright: `/model o3` style stubs matched
|
|
61
|
+
* dozens of unrelated ids by substring and the shortest-id tiebreak then picked
|
|
62
|
+
* an arbitrary one. A wrong model chosen silently is the failure mode this
|
|
63
|
+
* whole file is trying to end.
|
|
64
|
+
*/
|
|
65
|
+
export function matchCatalogId(want, ids) {
|
|
66
|
+
const w = normaliseModelId(want);
|
|
67
|
+
if (w.length < 3) return null;
|
|
68
|
+
const cands = (Array.isArray(ids) ? ids : [])
|
|
69
|
+
.filter((id) => typeof id === 'string' && id)
|
|
70
|
+
.map((id) => ({ id, norm: normaliseModelId(id) }));
|
|
71
|
+
const exact = cands.filter((c) => c.norm === w);
|
|
72
|
+
if (exact.length) return bestOf(exact);
|
|
73
|
+
const suffix = cands.filter((c) => c.norm.endsWith(`-${w}`));
|
|
74
|
+
if (suffix.length) return bestOf(suffix);
|
|
75
|
+
const contains = cands.filter((c) => c.norm.includes(w));
|
|
76
|
+
if (contains.length) return bestOf(contains);
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Up to `n` ids to suggest after a miss. matchCatalogId already tried plain
|
|
82
|
+
* substring, so this deliberately goes LOOSER — every dash-token of what was
|
|
83
|
+
* typed, then shrinking prefixes — or a typo like `fabel-5` would print
|
|
84
|
+
* "no model matches" with no way forward.
|
|
85
|
+
*/
|
|
86
|
+
export function closestIds(want, ids, n = 3) {
|
|
87
|
+
const w = normaliseModelId(want);
|
|
88
|
+
const cands = (Array.isArray(ids) ? ids : [])
|
|
89
|
+
.filter((id) => typeof id === 'string' && id)
|
|
90
|
+
.map((id) => ({ id, norm: normaliseModelId(id) }));
|
|
91
|
+
const probes = [];
|
|
92
|
+
for (const tok of w.split('-')) if (tok.length >= 3) probes.push(tok);
|
|
93
|
+
for (let len = w.length - 1; len >= 3; len -= 1) probes.push(w.slice(0, len));
|
|
94
|
+
const out = [];
|
|
95
|
+
const seen = new Set();
|
|
96
|
+
for (const probe of probes) {
|
|
97
|
+
const hits = cands.filter((c) => c.norm.includes(probe))
|
|
98
|
+
.sort((a, b) => a.norm.length - b.norm.length || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
|
|
99
|
+
for (const h of hits) {
|
|
100
|
+
if (seen.has(h.id)) continue;
|
|
101
|
+
seen.add(h.id);
|
|
102
|
+
out.push(h.id);
|
|
103
|
+
if (out.length >= n) return out;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return out;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Names people actually type, resolved against the LIVE catalog rather than
|
|
110
|
+
* hardcoded — a hardcoded list goes stale the week the zoo re-quotes. */
|
|
111
|
+
const POPULAR_HINTS = ['claude-fable-5', 'claude-opus-5', 'gpt-5', 'grok-4', 'gemini-3-pro'];
|
|
112
|
+
|
|
113
|
+
export function popularIds(ids, n = 5) {
|
|
114
|
+
const out = [];
|
|
115
|
+
const push = (id) => { if (id && !out.includes(id)) out.push(id); };
|
|
116
|
+
for (const hint of POPULAR_HINTS) push(matchCatalogId(hint, ids));
|
|
117
|
+
for (const id of Array.isArray(ids) ? ids : []) { if (out.length >= n) break; push(id); }
|
|
118
|
+
return out.slice(0, n);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// ---------------------------------------------------------------------------
|
|
122
|
+
// Detection: what did the user's LAST turn actually say?
|
|
123
|
+
// ---------------------------------------------------------------------------
|
|
124
|
+
|
|
125
|
+
/** Text of one message/item `content`, which is a bare string on the Chat
|
|
126
|
+
* Completions side and an array of typed parts on the Responses side
|
|
127
|
+
* (Codex sends `input_text`, browsers send `text`). */
|
|
128
|
+
function partsText(content) {
|
|
129
|
+
if (typeof content === 'string') return content;
|
|
130
|
+
if (!Array.isArray(content)) return '';
|
|
131
|
+
return content
|
|
132
|
+
.filter((p) => p && typeof p.text === 'string' && (!p.type || p.type === 'text' || p.type === 'input_text' || p.type === 'output_text'))
|
|
133
|
+
.map((p) => p.text)
|
|
134
|
+
.join('\n');
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* The last USER turn of a request body, for both shapes we are ever sent:
|
|
139
|
+
* `messages[]` (chat completions + Anthropic messages) and `input`
|
|
140
|
+
* (Responses API — what ChatGPT/Codex posts). Anything else returns ''.
|
|
141
|
+
*
|
|
142
|
+
* Last-user-turn only, never a scan of the whole conversation: a transcript
|
|
143
|
+
* that happens to quote "/model fable-5" from three turns ago must not
|
|
144
|
+
* re-trigger the switch on an unrelated question.
|
|
145
|
+
*/
|
|
146
|
+
export function lastUserText(body) {
|
|
147
|
+
if (!body || typeof body !== 'object') return '';
|
|
148
|
+
if (Array.isArray(body.messages)) {
|
|
149
|
+
for (let i = body.messages.length - 1; i >= 0; i -= 1) {
|
|
150
|
+
const m = body.messages[i];
|
|
151
|
+
if (m && m.role === 'user') return partsText(m.content);
|
|
152
|
+
}
|
|
153
|
+
return '';
|
|
154
|
+
}
|
|
155
|
+
if (typeof body.input === 'string') return body.input;
|
|
156
|
+
if (Array.isArray(body.input)) {
|
|
157
|
+
for (let i = body.input.length - 1; i >= 0; i -= 1) {
|
|
158
|
+
const it = body.input[i];
|
|
159
|
+
if (it && it.role === 'user') return partsText(it.content);
|
|
160
|
+
}
|
|
161
|
+
return '';
|
|
162
|
+
}
|
|
163
|
+
return '';
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* `{ arg }` when the last user turn IS the command, else null. Deliberately
|
|
168
|
+
* anchored: a message that merely mentions /model, or asks a question about
|
|
169
|
+
* it, is a real question and must be paid for and answered by a model.
|
|
170
|
+
*/
|
|
171
|
+
export function detectModelCommand(body) {
|
|
172
|
+
const text = String(lastUserText(body) || '').trim();
|
|
173
|
+
if (!text) return null;
|
|
174
|
+
const m = /^\/model(?:\s+(.+))?$/i.exec(text);
|
|
175
|
+
if (!m) return null;
|
|
176
|
+
return { arg: (m[1] || '').trim() };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// ---------------------------------------------------------------------------
|
|
180
|
+
// Persistence — one line in ~/.openzoo/model
|
|
181
|
+
// ---------------------------------------------------------------------------
|
|
182
|
+
|
|
183
|
+
/** Same directory as the wallet and proxy.log; there is no OPENZOO_HOME
|
|
184
|
+
* convention in this codebase, so homedir() it is (which honours $HOME, and
|
|
185
|
+
* that is how the tests get a throwaway one). */
|
|
186
|
+
export function modelFilePath(home = os.homedir()) {
|
|
187
|
+
return path.join(home, '.openzoo', 'model');
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Cached by (mtime,size) rather than read-once: `openzoo model X` in a second
|
|
191
|
+
// terminal writes this file while the proxy is running, and a plain in-memory
|
|
192
|
+
// cache would keep serving the old id until restart — the exact "no restart"
|
|
193
|
+
// promise the command makes.
|
|
194
|
+
let memo = { file: null, stamp: null, id: null };
|
|
195
|
+
|
|
196
|
+
export function readOverride(file = modelFilePath()) {
|
|
197
|
+
let stamp = 'missing';
|
|
198
|
+
try { const st = statSync(file); stamp = `${st.mtimeMs}:${st.size}`; } catch { /* not set */ }
|
|
199
|
+
if (memo.file === file && memo.stamp === stamp) return memo.id;
|
|
200
|
+
let id = null;
|
|
201
|
+
if (stamp !== 'missing') {
|
|
202
|
+
try { id = readFileSync(file, 'utf8').split('\n')[0].trim() || null; } catch { id = null; }
|
|
203
|
+
}
|
|
204
|
+
memo = { file, stamp, id };
|
|
205
|
+
return id;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export function writeOverride(id, file = modelFilePath()) {
|
|
209
|
+
mkdirSync(path.dirname(file), { recursive: true });
|
|
210
|
+
writeFileSync(file, `${id}\n`);
|
|
211
|
+
memo = { file: null, stamp: null, id: null };
|
|
212
|
+
return id;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export function clearOverride(file = modelFilePath()) {
|
|
216
|
+
try { unlinkSync(file); } catch { /* already gone */ }
|
|
217
|
+
memo = { file: null, stamp: null, id: null };
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const RESET_WORDS = new Set(['reset', 'default', 'off', 'none', 'clear']);
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Run the command and return what to say back. Pure apart from the one file
|
|
224
|
+
* write, so the reply text is testable.
|
|
225
|
+
*/
|
|
226
|
+
export function applyModelCommand(arg, ids, file = modelFilePath()) {
|
|
227
|
+
const current = readOverride(file);
|
|
228
|
+
const wanted = String(arg || '').trim();
|
|
229
|
+
if (!wanted) {
|
|
230
|
+
return { text: statusText(current, ids), model: current || 'openzoo', changed: false };
|
|
231
|
+
}
|
|
232
|
+
if (RESET_WORDS.has(wanted.toLowerCase())) {
|
|
233
|
+
clearOverride(file);
|
|
234
|
+
return {
|
|
235
|
+
text: current
|
|
236
|
+
? `Cleared the model override (was ${current}). Chats now use whatever the app sends.`
|
|
237
|
+
: 'No model override was set. Chats use whatever the app sends.',
|
|
238
|
+
model: 'openzoo',
|
|
239
|
+
changed: Boolean(current),
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
const hit = matchCatalogId(wanted, ids);
|
|
243
|
+
if (!hit) {
|
|
244
|
+
// A catalog we could not reach is NOT a bad name. Refusing here would make
|
|
245
|
+
// the command useless exactly when the gateway is flaky, so take the id as
|
|
246
|
+
// typed and say so — the next real request surfaces a wrong id anyway.
|
|
247
|
+
if (!Array.isArray(ids) || !ids.length) {
|
|
248
|
+
writeOverride(wanted, file);
|
|
249
|
+
return {
|
|
250
|
+
text: `Could not reach the model catalog, so I took "${wanted}" as typed. Every chat from here uses it until you send /model reset.`,
|
|
251
|
+
model: wanted,
|
|
252
|
+
changed: true,
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
const near = closestIds(wanted, ids);
|
|
256
|
+
return {
|
|
257
|
+
text: near.length
|
|
258
|
+
? `No model matches "${wanted}". Closest: ${near.join(', ')}.`
|
|
259
|
+
: `No model matches "${wanted}". Send /model on its own to see some ids.`,
|
|
260
|
+
model: current || 'openzoo',
|
|
261
|
+
changed: false,
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
writeOverride(hit, file);
|
|
265
|
+
return {
|
|
266
|
+
text: `Switched to ${hit}. Every chat from here uses it until you send /model reset.`,
|
|
267
|
+
model: hit,
|
|
268
|
+
changed: hit !== current,
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
export function statusText(current, ids) {
|
|
273
|
+
const lines = [current
|
|
274
|
+
? `Model override: ${current} — every chat uses it until you send /model reset.`
|
|
275
|
+
: 'Model override: none, using what the app sends.'];
|
|
276
|
+
const pop = popularIds(ids);
|
|
277
|
+
if (pop.length) {
|
|
278
|
+
lines.push('', 'Popular ids:');
|
|
279
|
+
for (const id of pop) lines.push(` ${id}`);
|
|
280
|
+
}
|
|
281
|
+
lines.push('', 'Usage: /model <name> · /model reset');
|
|
282
|
+
return lines.join('\n');
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// ---------------------------------------------------------------------------
|
|
286
|
+
// Reply synthesisers — one per wire shape the proxy serves
|
|
287
|
+
// ---------------------------------------------------------------------------
|
|
288
|
+
|
|
289
|
+
/** Which of the four paid shapes is this? The reply has to be in the SHAPE the
|
|
290
|
+
* client parses; a chat.completion sent to Codex's Responses parser is a hard
|
|
291
|
+
* client error, which reads to the user as "the proxy is broken". */
|
|
292
|
+
export function shapeForPath(rawPath) {
|
|
293
|
+
const p = String(rawPath || '');
|
|
294
|
+
if (/\/responses$/.test(p)) return 'responses';
|
|
295
|
+
if (/\/messages$/.test(p)) return 'anthropic';
|
|
296
|
+
return 'chat';
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
export function chatCompletionReply(text, model, now = Date.now()) {
|
|
300
|
+
return {
|
|
301
|
+
id: `chatcmpl-model-${now}`,
|
|
302
|
+
object: 'chat.completion',
|
|
303
|
+
created: Math.floor(now / 1000),
|
|
304
|
+
model,
|
|
305
|
+
choices: [{ index: 0, message: { role: 'assistant', content: text }, finish_reason: 'stop' }],
|
|
306
|
+
usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 },
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
export function responsesReply(text, model, now = Date.now()) {
|
|
311
|
+
return {
|
|
312
|
+
id: `resp_model_${now}`,
|
|
313
|
+
object: 'response',
|
|
314
|
+
created_at: Math.floor(now / 1000),
|
|
315
|
+
status: 'completed',
|
|
316
|
+
model,
|
|
317
|
+
output: [{
|
|
318
|
+
type: 'message',
|
|
319
|
+
id: `msg_model_${now}`,
|
|
320
|
+
status: 'completed',
|
|
321
|
+
role: 'assistant',
|
|
322
|
+
content: [{ type: 'output_text', text, annotations: [] }],
|
|
323
|
+
}],
|
|
324
|
+
usage: { input_tokens: 0, output_tokens: 0, total_tokens: 0 },
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* The Responses SSE script, in the order Codex's parser walks it. Codex
|
|
330
|
+
* ALWAYS streams, so this — not the JSON object above — is the path that
|
|
331
|
+
* actually runs in the app. `sequence_number` increments from 0 on every
|
|
332
|
+
* event: the parser tolerates gaps today, but an out-of-order or missing
|
|
333
|
+
* counter is the first thing that breaks when it stops tolerating them.
|
|
334
|
+
*/
|
|
335
|
+
export function responsesStreamEvents(text, model, now = Date.now()) {
|
|
336
|
+
const id = `resp_model_${now}`;
|
|
337
|
+
const itemId = `msg_model_${now}`;
|
|
338
|
+
const head = { id, object: 'response', created_at: Math.floor(now / 1000), model };
|
|
339
|
+
const item = (status, content) => ({ type: 'message', id: itemId, status, role: 'assistant', content });
|
|
340
|
+
const finished = item('completed', [{ type: 'output_text', text, annotations: [] }]);
|
|
341
|
+
let seq = 0;
|
|
342
|
+
const ev = (event, data) => ({ event, data: { ...data, sequence_number: seq++ } });
|
|
343
|
+
return [
|
|
344
|
+
ev('response.created', { type: 'response.created', response: { ...head, status: 'in_progress', output: [] } }),
|
|
345
|
+
ev('response.output_item.added', { type: 'response.output_item.added', output_index: 0, item: item('in_progress', []) }),
|
|
346
|
+
ev('response.content_part.added', { type: 'response.content_part.added', output_index: 0, item_id: itemId, content_index: 0, part: { type: 'output_text', text: '' } }),
|
|
347
|
+
ev('response.output_text.delta', { type: 'response.output_text.delta', output_index: 0, item_id: itemId, content_index: 0, delta: text }),
|
|
348
|
+
ev('response.output_text.done', { type: 'response.output_text.done', output_index: 0, item_id: itemId, content_index: 0, text }),
|
|
349
|
+
ev('response.content_part.done', { type: 'response.content_part.done', output_index: 0, item_id: itemId, content_index: 0, part: { type: 'output_text', text, annotations: [] } }),
|
|
350
|
+
ev('response.output_item.done', { type: 'response.output_item.done', output_index: 0, item: finished }),
|
|
351
|
+
ev('response.completed', { type: 'response.completed', response: { ...head, status: 'completed', output: [finished], usage: { input_tokens: 0, output_tokens: 0, total_tokens: 0 } } }),
|
|
352
|
+
];
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
export function anthropicReply(text, model, now = Date.now()) {
|
|
356
|
+
return {
|
|
357
|
+
id: `msg_model_${now}`,
|
|
358
|
+
type: 'message',
|
|
359
|
+
role: 'assistant',
|
|
360
|
+
model,
|
|
361
|
+
content: [{ type: 'text', text }],
|
|
362
|
+
stop_reason: 'end_turn',
|
|
363
|
+
stop_sequence: null,
|
|
364
|
+
usage: { input_tokens: 0, output_tokens: 0 },
|
|
365
|
+
};
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/** Anthropic's own event script. Same reason as the Responses one: a Messages
|
|
369
|
+
* client that asked for a stream and got a JSON body sits on a dead socket. */
|
|
370
|
+
export function anthropicStreamEvents(text, model, now = Date.now()) {
|
|
371
|
+
const message = { ...anthropicReply('', model, now), content: [], stop_reason: null };
|
|
372
|
+
return [
|
|
373
|
+
{ event: 'message_start', data: { type: 'message_start', message } },
|
|
374
|
+
{ event: 'content_block_start', data: { type: 'content_block_start', index: 0, content_block: { type: 'text', text: '' } } },
|
|
375
|
+
{ event: 'content_block_delta', data: { type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text } } },
|
|
376
|
+
{ event: 'content_block_stop', data: { type: 'content_block_stop', index: 0 } },
|
|
377
|
+
{ event: 'message_delta', data: { type: 'message_delta', delta: { stop_reason: 'end_turn', stop_sequence: null }, usage: { output_tokens: 0 } } },
|
|
378
|
+
{ event: 'message_stop', data: { type: 'message_stop' } },
|
|
379
|
+
];
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
export const SSE_HEADERS = {
|
|
383
|
+
'content-type': 'text/event-stream; charset=utf-8',
|
|
384
|
+
'cache-control': 'no-cache',
|
|
385
|
+
// Same reason as serveAsSse in proxy.js: a buffering hop in front of us
|
|
386
|
+
// (quick tunnel, nginx) merges frames and the client sees nothing until the
|
|
387
|
+
// end, which looks like a hang.
|
|
388
|
+
'x-accel-buffering': 'no',
|
|
389
|
+
connection: 'keep-alive',
|
|
390
|
+
};
|
|
391
|
+
|
|
392
|
+
export function writeSseEvents(res, events) {
|
|
393
|
+
res.writeHead(200, SSE_HEADERS);
|
|
394
|
+
for (const { event, data } of events) res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
|
|
395
|
+
res.end();
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* Answer the command on the socket, in whatever shape/streaming mode the
|
|
400
|
+
* client asked for. `serveAsSse` is injected rather than imported so this
|
|
401
|
+
* module stays free of proxy.js (which would be a cycle) and so the tests can
|
|
402
|
+
* pass a recording fake.
|
|
403
|
+
*/
|
|
404
|
+
export function serveModelCommand(res, { rawPath, stream, text, model, serveAsSse, now = Date.now() }) {
|
|
405
|
+
const shape = shapeForPath(rawPath);
|
|
406
|
+
if (shape === 'responses') {
|
|
407
|
+
if (stream) { writeSseEvents(res, responsesStreamEvents(text, model, now)); return shape; }
|
|
408
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
409
|
+
res.end(JSON.stringify(responsesReply(text, model, now)));
|
|
410
|
+
return shape;
|
|
411
|
+
}
|
|
412
|
+
if (shape === 'anthropic') {
|
|
413
|
+
if (stream) { writeSseEvents(res, anthropicStreamEvents(text, model, now)); return shape; }
|
|
414
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
415
|
+
res.end(JSON.stringify(anthropicReply(text, model, now)));
|
|
416
|
+
return shape;
|
|
417
|
+
}
|
|
418
|
+
const data = chatCompletionReply(text, model, now);
|
|
419
|
+
if (stream && typeof serveAsSse === 'function') { serveAsSse(res, data, null); return shape; }
|
|
420
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
421
|
+
res.end(JSON.stringify(data));
|
|
422
|
+
return shape;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/** Catalog ids for the CLI, which has no proxy cache to borrow. Soft-fails to
|
|
426
|
+
* [] so `openzoo model x` still sets an override with the gateway down. */
|
|
427
|
+
export async function catalogIds(apiBase = config.apiBase) {
|
|
428
|
+
try {
|
|
429
|
+
const r = await fetch(`${apiBase}/v1/models`, { signal: AbortSignal.timeout(8000) });
|
|
430
|
+
if (!r.ok) return [];
|
|
431
|
+
const payload = await r.json();
|
|
432
|
+
return quoteableRows(payload?.data).map((m) => m.id);
|
|
433
|
+
} catch { return []; }
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/** `openzoo model [id|reset]` — same override file the chat command writes, so
|
|
437
|
+
* a terminal and the chat box cannot disagree about which model is live. */
|
|
438
|
+
export async function cliModel(arg) {
|
|
439
|
+
const ids = await catalogIds();
|
|
440
|
+
const out = applyModelCommand(arg, ids);
|
|
441
|
+
console.log(out.text);
|
|
442
|
+
if (out.changed) console.log('(a running proxy picks this up on its next request — no restart)');
|
|
443
|
+
}
|
package/lib/ozSpendChip.js
CHANGED
|
@@ -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/proxy.js
CHANGED
|
@@ -14,6 +14,7 @@ import { tokenBalance } from './x402.js';
|
|
|
14
14
|
import { evmTokenBalance } from './evm.js';
|
|
15
15
|
import { autoContext } from './autobind.js';
|
|
16
16
|
import { modelsListForRequest, isHarnessAliasId, resolveModel, quoteableRows, unopenrouter } from './models.js';
|
|
17
|
+
import { detectModelCommand, applyModelCommand, serveModelCommand, readOverride } from './modelcommand.js';
|
|
17
18
|
|
|
18
19
|
/**
|
|
19
20
|
* Quoteable catalog ids, cached 5 minutes, for the fuzzy /v1/models/<id> probe.
|
|
@@ -440,6 +441,10 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
|
|
|
440
441
|
lastQuoteUsd = n > 1 ? billed / n : billed;
|
|
441
442
|
};
|
|
442
443
|
let tunnelSpent = 0;
|
|
444
|
+
// Last `from -> to` model rewrite we announced. The override applies to EVERY
|
|
445
|
+
// paid request, and saying so on every one buries the payment lines it sits
|
|
446
|
+
// next to; the user needs to know once, when it changes.
|
|
447
|
+
let saidOverride = null;
|
|
443
448
|
// Live balance refresh state — the real implementation is assigned in the
|
|
444
449
|
// banner section below; the handler only ever calls scheduleRefresh().
|
|
445
450
|
let lastSnap = null;
|
|
@@ -763,6 +768,40 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
|
|
|
763
768
|
servedRequests += 1;
|
|
764
769
|
say(`\n<- request #${servedRequests} from ${(req.headers['user-agent'] || 'unknown').slice(0, 40)}`);
|
|
765
770
|
try { wantsStream = JSON.parse(bodyBuf.toString('utf8'))?.stream === true; } catch { /* not JSON */ }
|
|
771
|
+
// `/model <name>` IS A COMMAND, NOT A QUESTION.
|
|
772
|
+
//
|
|
773
|
+
// A user typed `/model fable-5` into ChatGPT desktop (Codex side, pointed
|
|
774
|
+
// here). The app has no such command, so it sent the line to the model as
|
|
775
|
+
// an ordinary turn — which then spent six minutes and real money shelling
|
|
776
|
+
// around the gateway trying to work out what was being asked, and the
|
|
777
|
+
// model never changed. Answer it here instead: no upstream call, no 402,
|
|
778
|
+
// no payment, and the choice is remembered in ~/.openzoo/model so it
|
|
779
|
+
// outlives the proxy. Must sit AHEAD of the replay key and the pay path.
|
|
780
|
+
try {
|
|
781
|
+
const parsed = JSON.parse(bodyBuf.toString('utf8'));
|
|
782
|
+
const cmd = parsed ? detectModelCommand(parsed) : null;
|
|
783
|
+
if (cmd) {
|
|
784
|
+
let ids = [];
|
|
785
|
+
try { ids = await catalogIdsCached(`${config.apiBase}/v1/models`, upstreamHeaders(req)); } catch { /* offline: applyModelCommand takes the id as typed */ }
|
|
786
|
+
const out = applyModelCommand(cmd.arg, ids);
|
|
787
|
+
say(` /model ${cmd.arg || '(status)'} — answered locally, nothing paid`);
|
|
788
|
+
if (out.changed) saidOverride = null;
|
|
789
|
+
serveModelCommand(res, {
|
|
790
|
+
rawPath, stream: wantsStream, text: out.text, model: out.model, serveAsSse,
|
|
791
|
+
});
|
|
792
|
+
return;
|
|
793
|
+
}
|
|
794
|
+
// THE OVERRIDE ITSELF. Applied before the replay key so a switched
|
|
795
|
+
// model is a different cache entry, and before pay so the 402 is quoted
|
|
796
|
+
// for the model that will actually answer.
|
|
797
|
+
const override = readOverride();
|
|
798
|
+
if (override && parsed && typeof parsed.model === 'string' && parsed.model !== override) {
|
|
799
|
+
const pair = `${parsed.model} -> ${override}`;
|
|
800
|
+
if (saidOverride !== pair) { say(` model override: ${pair}`); saidOverride = pair; }
|
|
801
|
+
parsed.model = override;
|
|
802
|
+
bodyBuf = Buffer.from(JSON.stringify(parsed));
|
|
803
|
+
}
|
|
804
|
+
} catch { /* not JSON */ }
|
|
766
805
|
// UNOPENROUTER THE MODEL ID before anything downstream sees the body —
|
|
767
806
|
// the replay key, the outage gate, the wire. A vendor-prefixed
|
|
768
807
|
// OpenRouter spelling becomes the bare id the doors serve when the
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "openzoo",
|
|
3
|
-
"version": "0.51.
|
|
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",
|