openzoo 0.49.9 → 0.49.11

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/lib/launch.js CHANGED
@@ -1,15 +1,13 @@
1
1
  /**
2
- * `npx openzoo claude [args...]` — install and/or launch `openzoo-claude`
3
- * (the staccDOTsol fork of open-claude-code) already pointed at the local zoo.
2
+ * `npx openzoo claude [args...]` — launch Claude Code (or any Anthropic-shaped
3
+ * harness) already pointed at the local zoo, so its inference is paid per turn
4
+ * over x402 instead of hitting Anthropic directly.
4
5
  *
5
- * This is the supported front door. We exec that package's binary
6
- * (`openzoo-claude` / `occ` / its `claude` bin), never Anthropic's bun
7
- * `~/.local/bin/claude`. First run prefers `npx -y openzoo-claude` so there
8
- * is no global-install dance and no official Anthropic installer.
9
- *
10
- * ANTHROPIC_BASE_URL is the env the harness reads at startup. The proxy
11
- * serves POST /v1/messages (see lib/anthropic.js). We boot :8402 first if
12
- * needed, then spawn with the same zoo env pair — no second key/url pair.
6
+ * This is the supported front door: ANTHROPIC_BASE_URL is an official env var
7
+ * Claude Code reads at startup. No DNS games, no TLS interception — the proxy
8
+ * serves POST /v1/messages (see lib/anthropic.js) and this just spawns the
9
+ * harness with the two env vars set. The proxy must already be running
10
+ * (`npx openzoo` in another terminal); we check first and say so if not.
13
11
  */
14
12
  import { spawn, spawnSync } from 'node:child_process';
15
13
  import fs from 'node:fs';
@@ -60,9 +58,8 @@ function resolveClaudeDesktop() {
60
58
  return null;
61
59
  }
62
60
 
63
- /** Extra bin dirs (Homebrew, ~/.local/bin, npm-global-ish). Finder-launched
64
- * grokui has no ~/.zshrc PATH, so PATH-only lookup misses user bins. We
65
- * still scan these for `openzoo-claude` / `occ` — not for Anthropic bun. */
61
+ /** Dirs Claude's install.sh uses, plus Homebrew. Finder-launched grokui
62
+ * has no ~/.zshrc PATH, so PATH-only lookup misses `~/.local/bin/claude`. */
66
63
  export function claudeCodeBinDirs(home = os.homedir()) {
67
64
  return [
68
65
  path.join(home, '.local', 'bin'),
@@ -71,141 +68,67 @@ export function claudeCodeBinDirs(home = os.homedir()) {
71
68
  ];
72
69
  }
73
70
 
74
- export const OPENZOO_CLAUDE_PACKAGE = 'openzoo-claude';
75
-
76
- function exeName(name) {
77
- return name + (process.platform === 'win32' ? '.cmd' : '');
78
- }
79
-
80
- function isExecutable(file) {
81
- try { fs.accessSync(file, fs.constants.X_OK); return true; } catch { return false; }
82
- }
83
-
84
- function readHeadBytes(file, n = 8192) {
85
- const fd = fs.openSync(file, 'r');
86
- try {
87
- const buf = Buffer.alloc(n);
88
- const got = fs.readSync(fd, buf, 0, n, 0);
89
- return buf.subarray(0, got);
90
- } finally {
91
- fs.closeSync(fd);
92
- }
93
- }
94
-
95
- function isNativeBinary(buf) {
96
- if (!buf || buf.length < 2) return false;
97
- if (buf[0] === 0x7f && buf[1] === 0x45 && buf[2] === 0x4c && buf[3] === 0x46) return true; // ELF
98
- if (buf[0] === 0x4d && buf[1] === 0x5a) return true; // PE
99
- if (buf.length >= 4) {
100
- const mag = buf.readUInt32BE(0);
101
- // Mach-O 32/64 + fat (incl. byte-swapped).
102
- if (mag === 0xfeedface || mag === 0xfeedfacf || mag === 0xcafebabe
103
- || mag === 0xcffaedfe || mag === 0xcefaedfe) return true;
104
- }
105
- return false;
106
- }
107
-
108
- /** True for Anthropic's bun `claude` (install.sh standalone or bun shebang). */
109
- export function isAnthropicBunClaude(filePath) {
71
+ /**
72
+ * Is this file something execvp can actually RUN?
73
+ *
74
+ * The +x bit says "you are allowed to run this", not "this is runnable". A
75
+ * truncated download, a half-written install, or a plain text stub all carry
76
+ * the bit happily, and execvp then fails with ENOEXEC — which surfaced to a
77
+ * user as the bare string `openzoo: spawn ENOEXEC` with no path, no cause, and
78
+ * nothing to act on. A script is runnable when it starts with a shebang; a
79
+ * binary when it carries a known image magic. Anything else is a bad candidate
80
+ * and we should keep looking rather than hand it to spawn.
81
+ */
82
+ export function isRunnableExecutable(file) {
83
+ let fd;
110
84
  try {
111
- const buf = readHeadBytes(filePath, 512);
112
- if (isNativeBinary(buf)) return true;
113
- const shebang = buf.toString('utf8').split(/\r?\n/, 1)[0] || '';
114
- return shebang.startsWith('#!') && /\bbun\b/i.test(shebang);
115
- } catch {
85
+ fd = fs.openSync(file, 'r');
86
+ const buf = Buffer.alloc(4);
87
+ const n = fs.readSync(fd, buf, 0, 4, 0);
88
+ if (n < 2) return false; // empty/truncated
89
+ if (buf[0] === 0x23 && buf[1] === 0x21) return true; // "#!" script
90
+ if (buf[0] === 0x4d && buf[1] === 0x5a) return true; // "MZ" PE (win)
91
+ if (n < 4) return false;
92
+ const be = buf.readUInt32BE(0);
93
+ if (be === 0x7f454c46) return true; // ELF
94
+ // Mach-O thin (32/64, either endianness) and universal/fat.
95
+ if (be === 0xfeedface || be === 0xfeedfacf) return true;
96
+ if (be === 0xcefaedfe || be === 0xcffaedfe) return true;
97
+ if (be === 0xcafebabe || be === 0xbebafeca) return true;
116
98
  return false;
117
- }
118
- }
119
-
120
- /** True when this file is the openzoo-claude package (name, realpath, or shim). */
121
- export function isOpenzooClaudeBin(filePath) {
122
- try {
123
- const real = fs.realpathSync(filePath);
124
- if (/(?:^|[\\/])openzoo-claude(?:[\\/]|$)/i.test(real)) return true;
125
- if (isAnthropicBunClaude(real)) return false;
126
- const buf = readHeadBytes(real, 8192);
127
- if (isNativeBinary(buf)) return false;
128
- return buf.toString('utf8').includes('openzoo-claude');
129
99
  } catch {
130
100
  return false;
101
+ } finally {
102
+ if (fd !== undefined) { try { fs.closeSync(fd); } catch { /* ignore */ } }
131
103
  }
132
104
  }
133
105
 
134
- function pathSearchDirs(env = process.env) {
135
- const extras = env.OPENZOO_CLAUDE_PATH_ONLY === '1'
136
- ? []
137
- : claudeCodeBinDirs(env.HOME || os.homedir());
138
- const pathDirs = String(env.PATH || '').split(path.delimiter).filter(Boolean);
139
- return [...new Set([...extras, ...pathDirs])].filter(Boolean);
140
- }
141
-
142
- function findNamedBin(name, env, { requireOpenzoo = false } = {}) {
143
- const bin = exeName(name);
144
- for (const dir of pathSearchDirs(env)) {
145
- const f = path.join(dir, bin);
146
- if (!isExecutable(f)) continue;
147
- if (requireOpenzoo && !isOpenzooClaudeBin(f)) continue;
148
- return f;
149
- }
150
- return null;
151
- }
152
-
153
- export function resolveNpx(env = process.env) {
154
- return findNamedBin('npx', env);
155
- }
156
-
157
106
  /**
158
- * Resolve the openzoo-claude TERMINAL CLI.
159
- * Prefers `openzoo-claude`, then package `occ`, then that package's `claude`
160
- * bin. Never returns Anthropic bun `~/.local/bin/claude`.
107
+ * Resolve the Claude Code TERMINAL CLI.
108
+ *
109
+ * NOTE THE SEARCH ORDER: claudeCodeBinDirs() is tried BEFORE $PATH, because a
110
+ * Finder-launched process has no shell PATH. The cost is that a broken
111
+ * ~/.local/bin/claude — the exact file a half-finished install leaves behind —
112
+ * outranks a working one further down $PATH. So candidates are now format
113
+ * checked, and a rejected one does not stop the search; it is recorded so the
114
+ * caller can say WHICH file was wrong instead of printing an errno.
161
115
  */
162
- export function resolveClaudeCli(env = process.env) {
163
- const named = findNamedBin('openzoo-claude', env);
164
- if (named) return named;
165
- const occ = findNamedBin('occ', env, { requireOpenzoo: true });
166
- if (occ) return occ;
167
- const pkgClaude = findNamedBin('claude', env, { requireOpenzoo: true });
168
- if (pkgClaude) return pkgClaude;
116
+ export function resolveClaudeCli(env = process.env, rejected = []) {
117
+ const name = 'claude' + (process.platform === 'win32' ? '.cmd' : '');
118
+ const extras = env.OPENZOO_CLAUDE_PATH_ONLY === '1' ? [] : claudeCodeBinDirs();
119
+ const pathDirs = String(env.PATH || '').split(path.delimiter).filter(Boolean);
120
+ for (const dir of [...new Set([...extras, ...pathDirs])]) {
121
+ if (!dir) continue;
122
+ const f = path.join(dir, name);
123
+ try { fs.accessSync(f, fs.constants.X_OK); } catch { continue; }
124
+ // A .cmd shim on Windows is dispatched by the shell, not execvp, so the
125
+ // magic-number test does not apply there.
126
+ if (process.platform === 'win32' || isRunnableExecutable(f)) return f;
127
+ rejected.push(f);
128
+ }
169
129
  return null;
170
130
  }
171
131
 
172
- /**
173
- * Path hit or `npx -y openzoo-claude` fallback. Null only when neither the
174
- * package nor npx is available.
175
- */
176
- export function resolveOpenzooClaude(env = process.env) {
177
- const cli = resolveClaudeCli(env);
178
- if (cli) return { command: cli, prefixArgs: [], via: 'path' };
179
- const npx = resolveNpx(env);
180
- if (npx) return { command: npx, prefixArgs: ['-y', OPENZOO_CLAUDE_PACKAGE], via: 'npx' };
181
- return null;
182
- }
183
-
184
- /** Spawn argv for `openzoo claude [args]` — `--help` execs openzoo-claude. */
185
- export function claudeSpawnSpec(argv = [], env = process.env) {
186
- const rest = argv.filter((a) => !['--terminal', '-t', '--desktop'].includes(a));
187
- const resolved = resolveOpenzooClaude(env);
188
- if (!resolved) return null;
189
- return {
190
- command: resolved.command,
191
- args: [...resolved.prefixArgs, ...rest],
192
- via: resolved.via,
193
- };
194
- }
195
-
196
- function zooAuthToken(baseEnv) {
197
- if (baseEnv.ANTHROPIC_AUTH_TOKEN) return baseEnv.ANTHROPIC_AUTH_TOKEN;
198
- const envKey = String(baseEnv.OPENZOO_SUBSCRIPTION_KEY || '').trim();
199
- if (envKey) return envKey;
200
- const file = baseEnv.OPENZOO_SUBSCRIPTION_PATH
201
- || path.join(baseEnv.HOME || os.homedir(), '.openzoo', 'subscription.json');
202
- try {
203
- const key = String(JSON.parse(fs.readFileSync(file, 'utf8'))?.key || '').trim();
204
- if (key) return key;
205
- } catch { /* no subscription file */ }
206
- return 'sk-openzoo';
207
- }
208
-
209
132
  /**
210
133
  * The same env `openzoo claude` writes: gateway auth at the local proxy,
211
134
  * Anthropic API key UNSET (it would bill api.anthropic.com), catalog
@@ -218,8 +141,8 @@ export function claudeZooEnv(baseEnv = process.env, { base, port } = {}) {
218
141
  const env = { ...baseEnv };
219
142
  delete env.ANTHROPIC_API_KEY;
220
143
  env.ANTHROPIC_BASE_URL = url;
221
- env.ANTHROPIC_AUTH_TOKEN = zooAuthToken(baseEnv);
222
- const extras = claudeCodeBinDirs(baseEnv.HOME || os.homedir()).filter((d) => {
144
+ env.ANTHROPIC_AUTH_TOKEN = baseEnv.ANTHROPIC_AUTH_TOKEN || 'sk-openzoo';
145
+ const extras = claudeCodeBinDirs().filter((d) => {
223
146
  try { return fs.existsSync(d); } catch { return false; }
224
147
  });
225
148
  const pathDirs = String(env.PATH || '').split(path.delimiter).filter(Boolean);
@@ -258,9 +181,9 @@ export function claudeZooEnv(baseEnv = process.env, { base, port } = {}) {
258
181
  }
259
182
 
260
183
  /**
261
- * `npx openzoo claude [args]` — launch openzoo-claude on the zoo.
262
- * Terminal (openzoo-claude) is the default; `--desktop` opens the Anthropic app.
263
- * Both get the same zoo env pair so inference pays x402 / subscription.
184
+ * `npx openzoo claude [dir] [--terminal]` — launch Claude on the zoo.
185
+ * DEFAULT is the desktop app; `--terminal` (or `-t`) runs the Claude Code CLI.
186
+ * Both get ANTHROPIC_BASE_URL so inference pays x402.
264
187
  */
265
188
  export async function launchClaude(argv) {
266
189
  // let, not const: startProxy can heal onto a different port and every URL
@@ -356,11 +279,19 @@ export async function launchClaude(argv) {
356
279
  const env = claudeZooEnv(process.env, { base });
357
280
 
358
281
  if (terminal) {
359
- const spec = claudeSpawnSpec(argv, env);
360
- if (!spec) {
361
- console.error('openzoo: openzoo-claude not found and npx is unavailable.');
362
- console.error(' install: npx -y openzoo-claude');
363
- console.error(' or: npm i -g openzoo-claude');
282
+ const rejected = [];
283
+ const cli = resolveClaudeCli(process.env, rejected);
284
+ if (!cli) {
285
+ if (rejected.length) {
286
+ // Found it, could not run it. Say so — "not found on PATH" is a lie
287
+ // here and sends the user off to reinstall something they already have.
288
+ console.error('openzoo: found `claude` but it is not a runnable executable:');
289
+ for (const f of rejected) console.error(` ${f}`);
290
+ console.error(' (no shebang and no binary magic — usually a truncated or half-finished install)');
291
+ console.error(' fix: reinstall Claude Code, or delete the broken file so another copy on PATH is used.');
292
+ } else {
293
+ console.error('openzoo: `claude` CLI not found on PATH — install Claude Code, or drop --terminal for the desktop app');
294
+ }
364
295
  process.exit(1);
365
296
  }
366
297
  // ALWAYS-ON HUD via Claude Code's NATIVE status line (the title bar is owned
@@ -472,17 +403,46 @@ export async function launchClaude(argv) {
472
403
  let wallet = '';
473
404
  try { const { PayClient } = await import('./pay.js'); wallet = new PayClient().address; } catch { /* optional */ }
474
405
  console.error('');
475
- console.error(' \x1b[38;5;208m●\x1b[0m openzoo — this openzoo-claude session routes through the zoo');
406
+ console.error(' \x1b[38;5;208m●\x1b[0m openzoo — this Claude Code session routes through the zoo');
476
407
  console.error(` endpoint : ${base}`);
477
- console.error(' models : GET /v1/models (quoteable catalog — Claude /model includes OpenRouter ids + Auto)');
408
+ console.error(' models : GET /v1/models (quoteable ids only — Claude /model is a short picker + Auto)');
478
409
  console.error(' auth : gateway token (ANTHROPIC_API_KEY unset — no api.anthropic.com billing)');
479
410
  if (wallet) console.error(` wallet : ${wallet}`);
480
411
  console.error(' spend : live in the status line below (bottom of screen); receipts in ~/.openzoo/proxy.log');
481
412
  console.error(' every turn pays x402.');
482
413
  console.error('');
483
- const child = spawn(spec.command, spec.args, { stdio: 'inherit', env });
414
+ // spawn() THROWS SYNCHRONOUSLY on ENOEXEC — the 'error' listener below is
415
+ // never reached, which is why this reached a user as the unprefixed
416
+ // `openzoo: spawn ENOEXEC` from the top-level handler. Catch it here where
417
+ // `cli` is in scope and the message can name the file.
418
+ let child;
419
+ try {
420
+ child = spawn(cli, rest, { stdio: 'inherit', env });
421
+ } catch (e) {
422
+ try { restoreStatus?.(); } catch { /* ignore */ }
423
+ console.error(`openzoo: cannot execute ${cli} (${e?.code || e?.message})`);
424
+ console.error(' it is marked executable but the OS refused to run it.');
425
+ console.error(` check: head -1 ${cli} and file ${cli}`);
426
+ console.error(' usually a truncated install, a wrong-architecture binary,');
427
+ console.error(' or a shebang pointing at an interpreter that no longer exists.');
428
+ process.exit(1);
429
+ }
484
430
  child.on('exit', (c) => { try { restoreStatus?.(); } catch { /* ignore */ } process.exit(c ?? 0); });
485
- child.on('error', (e) => { console.error(`openzoo: could not launch openzoo-claude: ${e.message}`); process.exit(1); });
431
+ child.on('error', (e) => {
432
+ // ENOEXEC reaches here only if the format check passed and the kernel
433
+ // still refused it — a wrong-architecture binary, or a shebang pointing
434
+ // at an interpreter that is gone. Name the file either way; the bare
435
+ // errno is unactionable.
436
+ if (e?.code === 'ENOEXEC' || e?.code === 'EACCES') {
437
+ console.error(`openzoo: cannot execute ${cli} (${e.code})`);
438
+ console.error(' the file exists and is marked executable, but the OS refused to run it.');
439
+ console.error(` check: head -1 ${cli} and file ${cli}`);
440
+ console.error(' a shebang pointing at a missing interpreter (e.g. a removed node) does this.');
441
+ } else {
442
+ console.error(`openzoo: could not launch claude (${cli}): ${e.message}`);
443
+ }
444
+ process.exit(1);
445
+ });
486
446
  return;
487
447
  }
488
448
 
package/lib/models.js CHANGED
@@ -366,6 +366,59 @@ export function publishModelList(payload, opts) {
366
366
  };
367
367
  }
368
368
 
369
+ function modelVersionKey(id) {
370
+ const m = String(id || '').match(/(\d+(?:\.\d+)*)/g);
371
+ if (!m) return [0];
372
+ return m[0].split('.').map((n) => Number(n) || 0);
373
+ }
374
+
375
+ function cmpModelVersion(a, b) {
376
+ const ka = modelVersionKey(a);
377
+ const kb = modelVersionKey(b);
378
+ for (let i = 0; i < Math.max(ka.length, kb.length); i++) {
379
+ const d = (kb[i] || 0) - (ka[i] || 0);
380
+ if (d) return d;
381
+ }
382
+ return String(a).localeCompare(String(b));
383
+ }
384
+
385
+ function newestMatching(ids, pred) {
386
+ const hits = ids.filter((id) => pred(id) && !String(id).includes(':') && !/-fast(?:$|:)/i.test(id));
387
+ hits.sort(cmpModelVersion);
388
+ return hits[0] || null;
389
+ }
390
+
391
+ /** Preferred gateway flagships for the Claude Code picker, if quoteable. */
392
+ export const CLAUDE_PICKER_GATEWAY = [
393
+ { prefer: 'x-ai/grok-4.6', re: /^x-ai\/grok-/ },
394
+ { prefer: 'google/gemini-3.7-flash', re: /^google\/gemini-/ },
395
+ { prefer: 'deepseek/deepseek-v4-pro-0813', re: /^deepseek\// },
396
+ { prefer: 'qwen/qwen3.8-2.4t-a95b', re: /^qwen\// },
397
+ ];
398
+
399
+ /**
400
+ * Short honest Claude Code /model list: current Opus/Sonnet/Haiku/Fable
401
+ * plus a few real gateway models plus Auto. Only ids already in `rows`.
402
+ */
403
+ export function pickClaudePickerRows(rows) {
404
+ const list = Array.isArray(rows) ? rows : [];
405
+ const byId = new Map(list.map((m) => [m.id, m]));
406
+ const ids = list.map((m) => m.id);
407
+ const picked = [];
408
+ const add = (id) => {
409
+ if (id && byId.has(id) && !picked.includes(id)) picked.push(id);
410
+ };
411
+ add(newestMatching(ids, (id) => /^anthropic\/claude-opus/.test(id)));
412
+ add(newestMatching(ids, (id) => /^anthropic\/claude-sonnet/.test(id)));
413
+ add(newestMatching(ids, (id) => /claude-(?:3-)?haiku/.test(id)));
414
+ add(newestMatching(ids, (id) => /^anthropic\/claude-fable/.test(id)));
415
+ for (const { prefer, re } of CLAUDE_PICKER_GATEWAY) {
416
+ add(ids.includes(prefer) ? prefer : newestMatching(ids, (id) => re.test(id)));
417
+ }
418
+ add(AUTO_MODEL_ID);
419
+ return picked.map((id) => byId.get(id)).filter(Boolean);
420
+ }
421
+
369
422
  function anthropicRows(rows) {
370
423
  return rows.map((m) => ({
371
424
  type: 'model',
@@ -376,27 +429,14 @@ function anthropicRows(rows) {
376
429
  }));
377
430
  }
378
431
 
379
- /** openzoo/auto first so Claude Code's picker default is the router, not opus. */
380
- function withAutoFirst(rows) {
381
- const list = Array.isArray(rows) ? rows.slice() : [];
382
- const i = list.findIndex((m) => isAutoModel(m?.id));
383
- if (i > 0) {
384
- const [auto] = list.splice(i, 1);
385
- list.unshift(auto);
386
- }
387
- return list;
388
- }
389
-
390
432
  /**
391
433
  * Anthropic GET /v1/models shape (id + display_name + type).
392
434
  * Claude Code gateway discovery reads `data[].id` and optional `display_name`.
393
- * Full quoteable catalog — every priced published id, including OpenRouter
394
- * grok/gemini/gpt rows. Not a 4-id Anthropic cap, not openzoo-* clones.
395
- * Picker ≠ classifier: rewriteChatModel still pins 16-token classify off opus-5.
435
+ * Short picker — not the 300+ OpenRouter dump and not openzoo-* clones.
396
436
  */
397
437
  export function anthropicModelList(payload) {
398
438
  const published = publishModelList(payload, { aliases: false });
399
- const data = anthropicRows(withAutoFirst(published.data || []));
439
+ const data = anthropicRows(pickClaudePickerRows(published.data || []));
400
440
  return {
401
441
  data,
402
442
  has_more: false,
@@ -416,10 +456,8 @@ export function wantsAnthropicModelList(headers = {}) {
416
456
 
417
457
  /**
418
458
  * Body for GET /v1/models. Quoteable catalog only (never opus-5-only, never
419
- * unpriced / :batch / openzoo-* clones). Anthropic-shaped clients get the
420
- * same quoteable ids in Anthropic shape (type / id / display_name) so Claude
421
- * Code can select grok, gemini, gpt, etc. OpenAI clients keep object:list
422
- * of every quoteable id + harness aliases.
459
+ * the raw OpenRouter dump). Anthropic-shaped clients get the short picker;
460
+ * OpenAI clients keep object:list of every quoteable id + harness aliases.
423
461
  */
424
462
  export function modelsListForRequest(payload, headers) {
425
463
  return wantsAnthropicModelList(headers) ? anthropicModelList(payload) : publishModelList(payload);
package/lib/proxy.js CHANGED
@@ -30,6 +30,7 @@ import { injectBrief } from './brief.js';
30
30
  import { withNamespace } from './namespace.js';
31
31
  import { anthropicToOpenAI, openAIToAnthropic, streamOpenAIToAnthropic, writeAnthropicSse } from './anthropic.js';
32
32
  import { responsesToChat, chatToResponses, writeResponsesSse } from './responses.js';
33
+ import { streamOpenAIToResponses } from './responses-stream.js';
33
34
  import { loadSessionSpend, saveSessionSpend } from './session.js';
34
35
  import { creditBalance, quotedPrices } from './info.js';
35
36
  import { subscriptionPublicView } from './subscription.js';
@@ -37,7 +38,6 @@ import { priceHoldings } from './livestatus.js';
37
38
  import { receiptUsedCogs, receiptDirectUsd, pairActualBilled } from './racesettle.js';
38
39
  import { rewriteWrapClientError } from './wrap.js';
39
40
  import { fetchHeaders } from './fetch.js';
40
- import { relay } from './relay.js';
41
41
 
42
42
  const HOP_BY_HOP = new Set([
43
43
  'host', 'connection', 'keep-alive', 'transfer-encoding', 'upgrade',
@@ -87,6 +87,46 @@ async function readBody(req) {
87
87
  return Buffer.concat(chunks);
88
88
  }
89
89
 
90
+ /** Pipe an upstream fetch Response to the client, unbuffered (SSE-safe).
91
+ *
92
+ * `onReceipt` is called with the gateway's x402 block when it arrives. On a
93
+ * STREAMED call there is no JSON body to carry that block, so the gateway
94
+ * emits it as an SSE COMMENT (`: x402 {...}`) after the last frame — comments
95
+ * are discarded by every compliant client, so nothing downstream sees it, but
96
+ * without reading it here every spend and savings figure on the status line
97
+ * would silently read zero the moment real streaming was switched on.
98
+ *
99
+ * Sniffing NEVER delays a byte: each chunk is written to the client first and
100
+ * only then scanned. */
101
+ function relay(res, upstream, onReceipt) {
102
+ const headers = {};
103
+ upstream.headers.forEach((v, k) => {
104
+ if (!['transfer-encoding', 'connection', 'content-encoding', 'content-length'].includes(k)) headers[k] = v;
105
+ });
106
+ res.writeHead(upstream.status, headers);
107
+ if (!upstream.body) { res.end(); return Promise.resolve(); }
108
+ const sse = (upstream.headers.get('content-type') || '').includes('text/event-stream');
109
+ return new Promise((resolve) => {
110
+ const body = Readable.fromWeb(upstream.body);
111
+ body.on('error', () => res.destroy());
112
+ res.on('close', () => body.destroy());
113
+ body.on('end', resolve);
114
+ if (!sse || typeof onReceipt !== 'function') { body.pipe(res); return; }
115
+ let pending = '';
116
+ body.on('data', (c) => {
117
+ res.write(c);
118
+ pending += c.toString('utf8');
119
+ const lines = pending.split('\n');
120
+ pending = lines.pop() ?? ''; // a comment can straddle two chunks
121
+ for (const line of lines) {
122
+ if (!line.startsWith(': x402 ')) continue;
123
+ try { onReceipt(JSON.parse(line.slice(7))); } catch { /* not our frame */ }
124
+ }
125
+ });
126
+ body.on('end', () => res.end());
127
+ });
128
+ }
129
+
90
130
  function jsonErr(res, status, message, extraFields = {}) {
91
131
  res.writeHead(status, { 'content-type': 'application/json' });
92
132
  res.end(JSON.stringify({ error: { message }, ...extraFields }));
@@ -779,9 +819,6 @@ async function maybeCacheCorpus(req, bodyBuf, log, stats, extra = {}) {
779
819
  * default, so localhost behaviour is unchanged.
780
820
  */
781
821
  export async function startProxy({ silent = false, requireToken = null, sessionMaxUsd = null, autoTunnel = false } = {}) {
782
- // grokui's packed sidecar spawn sets OPENZOO_SILENT=1 so 400s land in
783
- // ~/.openzoo/proxy.log instead of Electron's inherited /dev/null.
784
- if (process.env.OPENZOO_SILENT === '1') silent = true;
785
822
  const client = new PayClient();
786
823
  const log = silent ? () => {} : (...a) => console.log(...a);
787
824
  // ALWAYS-ON. `silent: true` is used by the editor path to keep startup tidy,
@@ -1241,7 +1278,11 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
1241
1278
  }
1242
1279
  responsesModel = inbound.model;
1243
1280
  const meta = {};
1244
- bodyBuf = Buffer.from(JSON.stringify(responsesToChat(inbound, meta)));
1281
+ const translated = responsesToChat(inbound, meta);
1282
+ // Codex's watchdog is ~10s to first SSE byte. Force stream so we can
1283
+ // emit response.created immediately instead of buffering Opus.
1284
+ translated.stream = true;
1285
+ bodyBuf = Buffer.from(JSON.stringify(translated));
1245
1286
  responsesCustom = meta.custom;
1246
1287
  responsesMode = true;
1247
1288
  req.url = '/v1/chat/completions';
@@ -1816,6 +1857,39 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
1816
1857
  say(`credit -> $${x.billedUsd.toFixed(6)} · session $${sessionSpent.toFixed(6)}`);
1817
1858
  };
1818
1859
 
1860
+ // RESPONSES CLIENTS (Codex Security) GET THE SAME STREAM, IN THEIR GRAMMAR.
1861
+ // Without this, relay() pipes OpenAI SSE frames at a client waiting for
1862
+ // response.created, and the 10s watchdog fires ("connection interrupted").
1863
+ if (responsesMode && wantsStream
1864
+ && (response.headers.get('content-type') || '').includes('text/event-stream')
1865
+ && response.ok && response.body) {
1866
+ const tr = streamOpenAIToResponses(res, response, responsesModel, responsesCustom);
1867
+ let pending = '';
1868
+ const body = Readable.fromWeb(response.body);
1869
+ res.on('close', () => body.destroy());
1870
+ try {
1871
+ for await (const c of body) {
1872
+ pending += c.toString('utf8');
1873
+ const lines = pending.split('\n');
1874
+ pending = lines.pop() ?? '';
1875
+ for (const line of lines) {
1876
+ if (line.startsWith(': x402 ')) {
1877
+ try { meterStreamed(JSON.parse(line.slice(7))); } catch { /* not ours */ }
1878
+ continue;
1879
+ }
1880
+ if (!line.startsWith('data:')) continue;
1881
+ const payload = line.slice(5).trim();
1882
+ if (!payload || payload === '[DONE]') continue;
1883
+ try { tr.onChunk(JSON.parse(payload)); } catch { /* frame split */ }
1884
+ }
1885
+ }
1886
+ } catch (e) {
1887
+ log(`responses stream aborted: ${e.message}`);
1888
+ }
1889
+ tr.finish();
1890
+ return;
1891
+ }
1892
+
1819
1893
  // ANTHROPIC CLIENTS GET THE SAME STREAM, IN THEIR OWN GRAMMAR.
1820
1894
  // Translated frame by frame rather than buffered, so Claude Code sees
1821
1895
  // tokens as they are produced instead of nothing until the turn ends.
package/lib/racesettle.js CHANGED
@@ -255,7 +255,16 @@ export function isQuoteReserveBilled(billed, cost, x = {}) {
255
255
  * savings). Never return the reserve when we learned the real upstream cost.
256
256
  */
257
257
  export function receiptSettledBilled(x, usage) {
258
- const cost = money(usage?.cost) ?? money(x?.actualUsd) ?? money(usage?.actualUsd);
258
+ // The gateway now sets `usage.billedUsd` explicitly, and it is exactly the
259
+ // settled figure this function spends the rest of its body reconstructing
260
+ // from tokens and prices. Take it when it is there; the reconstruction below
261
+ // stays for older gateways and for the race path, which has no single
262
+ // billed number of its own.
263
+ const direct = money(usage?.billedUsd);
264
+ if (direct != null) return direct;
265
+ // See pairActualBilled: `usage.cost` is our price now, not upstream's.
266
+ const cost = money(usage?.cost_details?.upstream_inference_cost)
267
+ ?? money(usage?.cost) ?? money(x?.actualUsd) ?? money(usage?.actualUsd);
259
268
  const explicit = explicitSettledBilled(x, usage);
260
269
  if (explicit != null) return explicit;
261
270
 
@@ -295,7 +304,19 @@ function settledSavedUsd(x, cost) {
295
304
  * twin. Null when this call did not report a real cost — do not mix populations.
296
305
  */
297
306
  export function pairActualBilled(x402, usage) {
298
- const fromUsage = money(usage?.cost);
307
+ // `usage.cost` IS NO LONGER THE UPSTREAM FIGURE. The gateway now reports OUR
308
+ // price there, because that is the field every generic OpenAI-compatible
309
+ // client reads and they were all showing what OpenRouter charged us instead
310
+ // of what the caller was charged. The upstream number moved to
311
+ // `cost_details.upstream_inference_cost`, which is where OpenRouter itself
312
+ // publishes it.
313
+ //
314
+ // This is the HUD's DENOMINATOR, so reading the new `usage.cost` here would
315
+ // divide billed by billed and print 1× on every call regardless of markup.
316
+ // Prefer the explicit upstream field; fall back to `usage.cost` only for
317
+ // responses from a gateway old enough to predate the move.
318
+ const fromUpstream = money(usage?.cost_details?.upstream_inference_cost);
319
+ const fromUsage = fromUpstream ?? money(usage?.cost);
299
320
  const fromX = money(x402?.actualUsd) ?? money(x402?.usage?.cost);
300
321
  const upstreamUsd = fromUsage ?? fromX;
301
322
  if (upstreamUsd == null) return null;