openzoo 0.51.5 → 0.51.6

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
@@ -25,7 +25,18 @@ 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 Linux/macOS/Windows app)** — `openzoo chatgpt` puts the zoo in as the Codex provider in `~/.codex/config.toml`, starts a proxy if none is up, and launches the app; choose **sign in with API key** (any key) and the Codex / agent side pays x402 per turn. The plain ChatGPT chat tab still talks to chatgpt.com — it is not API-shaped. On Arch or anything without dpkg, `openzoo chatgpt --deb ~/Downloads/chatgpt_amd64.deb` unpacks the official .deb into `~/.openzoo/apps/chatgpt` and runs it from there (a `chatgpt*.deb` in `~/Downloads` is found automatically). `--no-launch` writes config only; `--print` shows the block.
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
+ 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
+
29
40
 
30
41
  **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
42
 
package/bin/openzoo.js CHANGED
@@ -146,16 +146,17 @@ 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 OpenAI's ChatGPT desktop app on the zoo. Writes the zoo as
150
- the Codex provider in ~/.codex/config.toml (other tables kept,
151
- old file backed up), starts a background proxy if none is up,
152
- and launches the app — the Codex / agent side pays x402 per
153
- turn; pick "sign in with API key" (any key). The plain chat
154
- tab still talks to chatgpt.com.
155
- Arch / no dpkg: --deb ~/Downloads/chatgpt_amd64.deb unpacks it
156
- (a chatgpt*.deb in ~/Downloads is picked up automatically)
157
- --model <id> (claude-opus-5) --wire responses|chat --bearer
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
158
  --no-launch (config only) --no-proxy --tunnel --print
159
+ -- <app flags>
159
160
  npx openzoo openclaw write the zoo into ~/.openclaw/openclaw.json as a model
160
161
  provider WITH REAL PRICES (OpenClaw's own custom-provider
161
162
  path hard-codes $0.00 and ignores /v1/models pricing)
@@ -180,6 +181,11 @@ usage:
180
181
  npx openzoo balance wallet balance on every rail — Solana (USDC/TOKEN/SOL),
181
182
  Base (USDC/ETH), Robinhood Chain (USDG/memecoins/ETH)
182
183
  npx openzoo address print both funding addresses (Solana + EVM)
184
+ npx openzoo model [id] pin the model every chat uses, whatever the app sends
185
+ (no id = show it · reset|off = clear). Same override the
186
+ chat command \`/model <id>\` sets from inside ChatGPT,
187
+ Cursor or Claude Code — a running proxy picks it up
188
+ without a restart.
183
189
  npx openzoo help this text
184
190
 
185
191
  point any OpenAI-compatible harness at:
@@ -420,6 +426,12 @@ async function main() {
420
426
  case 'address':
421
427
  (await import('../lib/info.js')).printAddress();
422
428
  break;
429
+ // Same override file the in-chat `/model` command writes. Two doors on one
430
+ // switch: a harness with no model picker (ChatGPT/Codex) uses the chat
431
+ // command, everyone else can just type it here.
432
+ case 'model':
433
+ await (await import('../lib/modelcommand.js')).cliModel(process.argv[3]);
434
+ break;
423
435
  case 'help':
424
436
  case '--help':
425
437
  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
+ }
package/lib/chatgpt.js CHANGED
@@ -1,38 +1,17 @@
1
1
  /**
2
2
  * `npx openzoo chatgpt` — OpenAI's ChatGPT desktop app on the zoo.
3
3
  *
4
- * The Linux preview of the official app (chatgpt.com/download, August 2026)
5
- * ships the Codex engine inside it: `usr/lib/chatgpt/resources/codex`, and
6
- * `usr/bin/chatgpt` is a symlink named `codex-launcher`. That engine reads a
7
- * standard Codex config — `$CODEX_HOME/config.toml`, default `~/.codex` — and
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 { installChatGpt, windowsChatGptApp, runCommand } from './chatgpt-install.js';
36
15
 
37
16
  export const PROVIDER_ID = 'openzoo';
38
17
  export const API_KEY_ENV = 'OPENZOO_API_KEY';
@@ -48,8 +27,15 @@ export function codexConfigPath(env = process.env, home = os.homedir()) {
48
27
  }
49
28
 
50
29
  export function defaultModel(env = process.env) {
30
+ // DEEPSEEK BY DEFAULT. The person running `openzoo chatgpt` has not chosen
31
+ // a model and mostly will not: the default is what they pay for on every
32
+ // turn until they type /model. It used to be claude-opus-5, the most
33
+ // expensive door in the catalog, so the "much less spend" pitch shipped
34
+ // with the priciest setting turned on. deepseek-v4-flash is a fraction of
35
+ // a cent per million tokens in, carries a 1M-token window, and is a bare
36
+ // id the doors serve directly. Anyone who wants more says /model.
51
37
  // Bare ids are what the doors serve; the gateway resolves the rest.
52
- return env.OPENZOO_DEFAULT_MODEL || 'claude-opus-5';
38
+ return env.OPENZOO_DEFAULT_MODEL || 'deepseek-v4-flash';
53
39
  }
54
40
 
55
41
  /**
@@ -113,7 +99,7 @@ export function mergeCodexConfig(existing, { model, port, wireApi, bearer } = {}
113
99
  });
114
100
  const head = [
115
101
  OUR_MARK,
116
- `model = "${model || defaultModel()}"`,
102
+ `model = ${JSON.stringify(model || defaultModel())}`,
117
103
  `model_provider = "${PROVIDER_ID}"`,
118
104
  ];
119
105
  // trim blank padding so reruns do not accumulate empty lines
@@ -134,14 +120,15 @@ export function mergeCodexConfig(existing, { model, port, wireApi, bearer } = {}
134
120
 
135
121
  /** Candidate app binaries, most specific first. Exported for tests. */
136
122
  export function chatGptCandidates({ env = process.env, home = os.homedir(), platform = process.platform } = {}) {
123
+ const paths = platform === 'win32' ? path.win32 : path;
137
124
  const out = [];
138
125
  if (env.OPENZOO_CHATGPT_BIN) out.push(env.OPENZOO_CHATGPT_BIN);
139
126
  if (platform === 'darwin') {
140
127
  out.push('/Applications/ChatGPT.app/Contents/MacOS/ChatGPT');
141
128
  out.push(path.join(home, 'Applications', 'ChatGPT.app', 'Contents', 'MacOS', 'ChatGPT'));
142
129
  } else if (platform === 'win32') {
143
- const local = env.LOCALAPPDATA || path.join(home, 'AppData', 'Local');
144
- out.push(path.join(local, 'Programs', 'ChatGPT', 'ChatGPT.exe'));
130
+ const local = env.LOCALAPPDATA || paths.join(home, 'AppData', 'Local');
131
+ out.push(paths.join(local, 'Programs', 'ChatGPT', 'ChatGPT.exe'));
145
132
  } else {
146
133
  // the .deb's layout (apt install), then where THIS command unpacks it,
147
134
  // then the by-hand unpack path the first Arch run used
@@ -150,15 +137,21 @@ export function chatGptCandidates({ env = process.env, home = os.homedir(), plat
150
137
  out.push(path.join(unpackDir(home), 'usr', 'lib', 'chatgpt', 'ChatGPT'));
151
138
  out.push(path.join(home, 'apps', 'chatgpt', 'usr', 'lib', 'chatgpt', 'ChatGPT'));
152
139
  }
153
- for (const dir of String(env.PATH || '').split(path.delimiter).filter(Boolean)) {
154
- out.push(path.join(dir, platform === 'win32' ? 'chatgpt.exe' : 'chatgpt'));
140
+ for (const dir of String(env.PATH || '').split(platform === 'win32' ? ';' : ':').filter(Boolean)) {
141
+ out.push(paths.join(dir, platform === 'win32' ? 'chatgpt.exe' : 'chatgpt'));
155
142
  }
156
143
  return out;
157
144
  }
158
145
 
159
146
  export function resolveChatGptApp(opts = {}) {
160
147
  const exists = opts.exists || ((p) => { try { fs.accessSync(p, fs.constants.X_OK); return true; } catch { return false; } });
161
- return chatGptCandidates(opts).find(exists) || null;
148
+ return chatGptCandidates(opts).find(p => {
149
+ if (!exists(p)) return false;
150
+ if ((opts.platform || process.platform) === 'darwin' && p.endsWith('/Contents/MacOS/ChatGPT')) {
151
+ return exists(path.resolve(path.dirname(p), '../Resources/codex'));
152
+ }
153
+ return true;
154
+ }) || null;
162
155
  }
163
156
 
164
157
  /** Where `--deb` (or an auto-found download) is unpacked. */
@@ -190,27 +183,35 @@ function have(bin) {
190
183
  */
191
184
  export function unpackDeb(deb, dest = unpackDir()) {
192
185
  if (!fs.existsSync(deb)) throw new Error(`no such file: ${deb}`);
193
- fs.rmSync(dest, { recursive: true, force: true });
194
- fs.mkdirSync(dest, { recursive: true });
186
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
195
187
  const outer = fs.mkdtempSync(path.join(os.tmpdir(), 'openzoo-deb-'));
188
+ const staged = fs.mkdtempSync(path.join(path.dirname(dest), '.chatgpt-unpack-'));
196
189
  const run = (cmd, args, cwd) => {
197
190
  const r = spawnSync(cmd, args, { cwd, stdio: ['ignore', 'ignore', 'pipe'] });
198
- if (r.status !== 0) throw new Error(`${cmd} ${args.join(' ')} failed: ${String(r.stderr || '').trim().slice(0, 200)}`);
191
+ if (r.status !== 0) throw new Error(`${cmd} failed: ${r.error?.message || String(r.stderr || '').trim().slice(0, 200)}`);
199
192
  };
200
- if (have('bsdtar')) run('bsdtar', ['-xf', deb], outer);
201
- else if (have('ar')) run('ar', ['x', deb], outer);
202
- else throw new Error('need bsdtar (libarchive) or ar (binutils) to unpack a .deb');
203
- const data = fs.readdirSync(outer).find((n) => /^data\.tar/.test(n));
204
- if (!data) throw new Error('not a .deb: no data.tar inside');
205
- run('tar', ['-xf', path.join(outer, data), '-C', dest]);
206
- fs.rmSync(outer, { recursive: true, force: true });
207
- const app = path.join(dest, 'usr', 'lib', 'chatgpt', 'ChatGPT');
208
- if (!fs.existsSync(app)) throw new Error(`unpacked, but ${app} is not there — not the ChatGPT .deb?`);
209
- return app;
193
+ try {
194
+ if (have('bsdtar')) run('bsdtar', ['-xf', deb], outer);
195
+ else if (have('ar')) run('ar', ['x', deb], outer);
196
+ else throw new Error('need bsdtar (libarchive) or ar (binutils) to unpack a .deb');
197
+ const data = fs.readdirSync(outer).find((n) => /^data\.tar/.test(n));
198
+ if (!data) throw new Error('not a .deb: no data.tar inside');
199
+ run('tar', ['-xf', path.join(outer, data), '-C', staged]);
200
+ const relative = path.join('usr', 'lib', 'chatgpt', 'ChatGPT');
201
+ if (!fs.existsSync(path.join(staged, relative))) throw new Error('package does not contain the ChatGPT desktop executable');
202
+ fs.accessSync(path.join(staged, relative), fs.constants.X_OK);
203
+ // Do not erase a previous unpack until the replacement has been validated.
204
+ fs.rmSync(dest, { recursive: true, force: true });
205
+ fs.renameSync(staged, dest);
206
+ return path.join(dest, relative);
207
+ } finally {
208
+ fs.rmSync(outer, { recursive: true, force: true });
209
+ fs.rmSync(staged, { recursive: true, force: true });
210
+ }
210
211
  }
211
212
 
212
213
  async function ensureProxy({ tunnel }) {
213
- const { startProxy, oursOn, packageVersion } = await import('./proxy.js');
214
+ const { oursOn, packageVersion } = await import('./proxy.js');
214
215
  if (await oursOn(config.port)) return { started: false };
215
216
  // DETACHED, like `openzoo aoe`: the app outlives this command, so the
216
217
  // proxy must too. No public tunnel unless asked — a detached proxy nobody
@@ -237,26 +238,27 @@ async function ensureProxy({ tunnel }) {
237
238
  throw new Error(`proxy did not answer on :${config.port} within 60s — see ${logPath}`);
238
239
  }
239
240
 
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
241
  /**
248
242
  * `npx openzoo chatgpt [--model <id>] [--deb <file>] [--wire responses|chat]
249
243
  * [--bearer] [--no-launch] [--no-proxy] [--tunnel] [--print]`
250
244
  */
251
- export async function setupChatGpt(argv = []) {
245
+ export async function setupChatGpt(argv = [], dependencies = {}) {
246
+ const { platform = process.platform, home = os.homedir(), env: sourceEnv = process.env,
247
+ resolve = resolveChatGptApp, install = installChatGpt, proxy = ensureProxy,
248
+ launch = launchChatGpt, findWindows = windowsChatGptApp } = dependencies;
252
249
  const args = [...argv];
253
250
  const opt = { model: null, deb: null, wireApi: 'responses', bearer: false, launch: true, proxy: true, tunnel: false, print: false, configPath: null, rest: [] };
254
251
  while (args.length) {
255
252
  const a = args.shift();
256
- if (a === '--model') opt.model = String(args.shift() || '').trim() || null;
257
- else if (a === '--deb') opt.deb = String(args.shift() || '').trim() || null;
258
- else if (a === '--wire') opt.wireApi = String(args.shift() || '').trim();
259
- else if (a === '--config') opt.configPath = String(args.shift() || '').trim() || null;
253
+ if (a === '--') { opt.rest.push(...args); break; }
254
+ const value = () => {
255
+ if (!args.length || args[0].startsWith('--') || !args[0].trim()) throw new Error(`${a} requires a value`);
256
+ return args.shift().trim();
257
+ };
258
+ if (a === '--model') opt.model = value();
259
+ else if (a === '--deb') opt.deb = value();
260
+ else if (a === '--wire') opt.wireApi = value();
261
+ else if (a === '--config') opt.configPath = value();
260
262
  else if (a === '--bearer') opt.bearer = true;
261
263
  else if (a === '--no-launch') opt.launch = false;
262
264
  else if (a === '--no-proxy') opt.proxy = false;
@@ -266,7 +268,8 @@ export async function setupChatGpt(argv = []) {
266
268
  else opt.rest.push(a);
267
269
  }
268
270
  if (!['responses', 'chat'].includes(opt.wireApi)) throw new Error('--wire must be responses or chat');
269
- const model = opt.model || defaultModel();
271
+ if (opt.deb && platform !== 'linux') throw new Error('--deb is only supported on Linux');
272
+ const model = opt.model || defaultModel(sourceEnv);
270
273
  const merge = { model, port: config.port, wireApi: opt.wireApi, bearer: opt.bearer };
271
274
 
272
275
  if (opt.print) {
@@ -275,54 +278,69 @@ export async function setupChatGpt(argv = []) {
275
278
  }
276
279
 
277
280
  // 1. config.toml
278
- const file = opt.configPath || codexConfigPath();
281
+ const appHome = sourceEnv.CODEX_HOME || path.join(home, '.openzoo', 'chatgpt');
282
+ const file = path.resolve(opt.configPath || path.join(appHome, 'config.toml'));
283
+ if (path.basename(file) !== 'config.toml') throw new Error('--config must name config.toml (the filename Codex reads)');
279
284
  let existing = '';
280
285
  if (fs.existsSync(file)) {
281
286
  existing = fs.readFileSync(file, 'utf8');
282
- fs.copyFileSync(file, `${file}.openzoo-backup`);
283
287
  } else {
284
288
  fs.mkdirSync(path.dirname(file), { recursive: true });
285
289
  }
286
290
  const next = mergeCodexConfig(existing, merge);
287
- if (next !== existing) fs.writeFileSync(file, next);
288
- console.log(`${next !== existing ? 'wrote' : 'kept'} ${file}: model_provider = "${PROVIDER_ID}" (http://localhost:${config.port}/v1, ${opt.wireApi}), model = "${model}"`);
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}`);
291
+ if (next !== existing) {
292
+ if (existing && !fs.existsSync(`${file}.openzoo-backup`)) fs.copyFileSync(file, `${file}.openzoo-backup`);
293
+ fs.writeFileSync(file, next);
294
294
  }
295
+ console.log(`${next !== existing ? 'wrote' : 'kept'} ${file}: model_provider = "${PROVIDER_ID}" (http://localhost:${config.port}/v1, ${opt.wireApi}), model = "${model}"`);
295
296
 
296
297
  if (!opt.launch) {
297
- console.log(`config only. launch the app with ${API_KEY_ENV}=${API_KEY_VALUE} in its environment and pick "sign in with API key".`);
298
+ console.log('config only; no download, installation, proxy or app launch.');
298
299
  return { file, model, launched: false };
299
300
  }
300
301
 
301
- // 3. the app
302
- let app = resolveChatGptApp();
303
- if (!app && process.platform === 'linux') {
304
- const deb = opt.deb || findDownloadedDeb();
305
- if (deb) {
306
- console.log(`unpacking ${deb} into ${unpackDir()} (no dpkg needed)`);
307
- app = unpackDeb(deb);
308
- }
309
- } else if (opt.deb) {
310
- app = unpackDeb(opt.deb);
311
- }
312
- if (!app) {
313
- console.error(INSTALL_HELP);
314
- process.exitCode = 1;
315
- return { file, model, launched: false };
302
+ // Reuse an installation before attempting any download.
303
+ let app = resolve({ env: sourceEnv, home, platform });
304
+ if (!app && sourceEnv.OPENZOO_CHATGPT_BIN) throw new Error(`OPENZOO_CHATGPT_BIN is not an executable ChatGPT app: ${sourceEnv.OPENZOO_CHATGPT_BIN}`);
305
+ if (!app && platform === 'win32') app = await findWindows();
306
+ if (!app && platform === 'linux' && opt.deb) app = unpackDeb(path.resolve(opt.deb), unpackDir(home));
307
+ if (!app) app = await install({ platform, home, unpack: unpackDeb });
308
+
309
+ if (opt.proxy) {
310
+ const p = await proxy({ tunnel: opt.tunnel });
311
+ console.log(p.started ? `proxy up on :${config.port} (background)` : `proxy already on :${config.port}`);
316
312
  }
317
313
 
318
314
  // 4. launch, with the env var the provider's env_key names
319
- const env = { ...process.env, [API_KEY_ENV]: process.env[API_KEY_ENV] || API_KEY_VALUE };
320
- if (!env.CODEX_HOME) env.CODEX_HOME = codexHome();
315
+ const env = { ...sourceEnv, [API_KEY_ENV]: sourceEnv[API_KEY_ENV] || API_KEY_VALUE,
316
+ CODEX_HOME: path.dirname(file),
317
+ CODEX_ELECTRON_USER_DATA_PATH: path.join(path.dirname(file), 'desktop'),
318
+ };
321
319
  console.log(`launching ${app}`);
322
320
  console.log(` Codex / agent mode → the zoo, paid per turn by x402. Choose "sign in with API key" (any key).`);
323
321
  console.log(' the plain ChatGPT chat tab still talks to chatgpt.com — that half is not API-shaped.');
324
- const child = spawn(app, opt.rest, { stdio: 'ignore', env, detached: true });
325
- child.on('error', (e) => console.error(`openzoo: could not launch ${app}: ${e.message}`));
326
- child.unref();
322
+ await launch(app, opt.rest, { env, platform });
327
323
  return { file, model, app, launched: true };
328
324
  }
325
+
326
+ /** LaunchServices handles app bundles on macOS; detect early failures elsewhere. */
327
+ export async function launchChatGpt(app, args, { env, platform = process.platform, run = runCommand, spawnImpl = spawn } = {}) {
328
+ if (platform === 'darwin' && app.endsWith('/Contents/MacOS/ChatGPT')) {
329
+ await run('open', ['-n', '--env', `CODEX_HOME=${env.CODEX_HOME}`,
330
+ '--env', `CODEX_ELECTRON_USER_DATA_PATH=${env.CODEX_ELECTRON_USER_DATA_PATH}`,
331
+ '--env', `${API_KEY_ENV}=${env[API_KEY_ENV]}`,
332
+ app.slice(0, -'/Contents/MacOS/ChatGPT'.length), '--args',
333
+ `--user-data-dir=${env.CODEX_ELECTRON_USER_DATA_PATH}`, ...args], { env });
334
+ return;
335
+ }
336
+ await new Promise((resolve, reject) => {
337
+ const child = spawnImpl(app, [`--user-data-dir=${env.CODEX_ELECTRON_USER_DATA_PATH}`, ...args], { stdio: 'ignore', env, detached: true });
338
+ const timer = setTimeout(() => { child.unref(); resolve(); }, 1500);
339
+ child.once('error', e => { clearTimeout(timer); reject(e); });
340
+ child.once('exit', (code, signal) => {
341
+ clearTimeout(timer);
342
+ if (code === 0) resolve(); // an existing instance accepted the launch
343
+ else reject(new Error(`ChatGPT exited during launch (${signal || code})`));
344
+ });
345
+ });
346
+ }
@@ -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/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.5",
3
+ "version": "0.51.6",
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",