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/README.md +18 -4
- package/bin/openzoo.js +2 -3
- package/lib/claudecode.js +107 -723
- package/lib/grokui.mjs +125 -548
- package/lib/launch.js +110 -150
- package/lib/models.js +57 -19
- package/lib/proxy.js +79 -5
- package/lib/racesettle.js +23 -2
- package/lib/responses-stream.js +176 -0
- package/package.json +2 -2
- package/lib/relay.js +0 -275
package/lib/launch.js
CHANGED
|
@@ -1,15 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `npx openzoo claude [args...]` —
|
|
3
|
-
*
|
|
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
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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
|
-
/**
|
|
64
|
-
*
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
|
|
112
|
-
|
|
113
|
-
const
|
|
114
|
-
|
|
115
|
-
|
|
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
|
|
159
|
-
*
|
|
160
|
-
*
|
|
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
|
|
164
|
-
|
|
165
|
-
const
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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 =
|
|
222
|
-
const extras = claudeCodeBinDirs(
|
|
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 [
|
|
262
|
-
*
|
|
263
|
-
* Both get
|
|
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
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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) => {
|
|
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
|
-
*
|
|
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(
|
|
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
|
-
*
|
|
420
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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;
|