openzoo 0.49.9 → 0.49.10
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 +29 -159
- 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,19 @@ export function claudeCodeBinDirs(home = os.homedir()) {
|
|
|
71
68
|
];
|
|
72
69
|
}
|
|
73
70
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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) {
|
|
110
|
-
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 {
|
|
116
|
-
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
|
-
} catch {
|
|
130
|
-
return false;
|
|
131
|
-
}
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
function pathSearchDirs(env = process.env) {
|
|
135
|
-
const extras = env.OPENZOO_CLAUDE_PATH_ONLY === '1'
|
|
136
|
-
? []
|
|
137
|
-
: claudeCodeBinDirs(env.HOME || os.homedir());
|
|
71
|
+
/** Resolve the Claude Code TERMINAL CLI. */
|
|
72
|
+
export function resolveClaudeCli(env = process.env) {
|
|
73
|
+
const name = 'claude' + (process.platform === 'win32' ? '.cmd' : '');
|
|
74
|
+
const extras = env.OPENZOO_CLAUDE_PATH_ONLY === '1' ? [] : claudeCodeBinDirs();
|
|
138
75
|
const pathDirs = String(env.PATH || '').split(path.delimiter).filter(Boolean);
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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;
|
|
76
|
+
for (const dir of [...new Set([...extras, ...pathDirs])]) {
|
|
77
|
+
if (!dir) continue;
|
|
78
|
+
const f = path.join(dir, name);
|
|
79
|
+
try { fs.accessSync(f, fs.constants.X_OK); return f; } catch { /* next */ }
|
|
149
80
|
}
|
|
150
81
|
return null;
|
|
151
82
|
}
|
|
152
83
|
|
|
153
|
-
export function resolveNpx(env = process.env) {
|
|
154
|
-
return findNamedBin('npx', env);
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
/**
|
|
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`.
|
|
161
|
-
*/
|
|
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;
|
|
169
|
-
return null;
|
|
170
|
-
}
|
|
171
|
-
|
|
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
84
|
/**
|
|
210
85
|
* The same env `openzoo claude` writes: gateway auth at the local proxy,
|
|
211
86
|
* Anthropic API key UNSET (it would bill api.anthropic.com), catalog
|
|
@@ -218,8 +93,8 @@ export function claudeZooEnv(baseEnv = process.env, { base, port } = {}) {
|
|
|
218
93
|
const env = { ...baseEnv };
|
|
219
94
|
delete env.ANTHROPIC_API_KEY;
|
|
220
95
|
env.ANTHROPIC_BASE_URL = url;
|
|
221
|
-
env.ANTHROPIC_AUTH_TOKEN =
|
|
222
|
-
const extras = claudeCodeBinDirs(
|
|
96
|
+
env.ANTHROPIC_AUTH_TOKEN = baseEnv.ANTHROPIC_AUTH_TOKEN || 'sk-openzoo';
|
|
97
|
+
const extras = claudeCodeBinDirs().filter((d) => {
|
|
223
98
|
try { return fs.existsSync(d); } catch { return false; }
|
|
224
99
|
});
|
|
225
100
|
const pathDirs = String(env.PATH || '').split(path.delimiter).filter(Boolean);
|
|
@@ -258,9 +133,9 @@ export function claudeZooEnv(baseEnv = process.env, { base, port } = {}) {
|
|
|
258
133
|
}
|
|
259
134
|
|
|
260
135
|
/**
|
|
261
|
-
* `npx openzoo claude [
|
|
262
|
-
*
|
|
263
|
-
* Both get
|
|
136
|
+
* `npx openzoo claude [dir] [--terminal]` — launch Claude on the zoo.
|
|
137
|
+
* DEFAULT is the desktop app; `--terminal` (or `-t`) runs the Claude Code CLI.
|
|
138
|
+
* Both get ANTHROPIC_BASE_URL so inference pays x402.
|
|
264
139
|
*/
|
|
265
140
|
export async function launchClaude(argv) {
|
|
266
141
|
// let, not const: startProxy can heal onto a different port and every URL
|
|
@@ -356,13 +231,8 @@ export async function launchClaude(argv) {
|
|
|
356
231
|
const env = claudeZooEnv(process.env, { base });
|
|
357
232
|
|
|
358
233
|
if (terminal) {
|
|
359
|
-
const
|
|
360
|
-
if (!
|
|
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');
|
|
364
|
-
process.exit(1);
|
|
365
|
-
}
|
|
234
|
+
const cli = resolveClaudeCli();
|
|
235
|
+
if (!cli) { console.error('openzoo: `claude` CLI not found on PATH — install Claude Code, or drop --terminal for the desktop app'); process.exit(1); }
|
|
366
236
|
// ALWAYS-ON HUD via Claude Code's NATIVE status line (the title bar is owned
|
|
367
237
|
// by Claude Code and gets overwritten, so OSC there is useless). We write a
|
|
368
238
|
// tiny status script that reads the proxy's /v1/info, and merge a statusLine
|
|
@@ -472,17 +342,17 @@ export async function launchClaude(argv) {
|
|
|
472
342
|
let wallet = '';
|
|
473
343
|
try { const { PayClient } = await import('./pay.js'); wallet = new PayClient().address; } catch { /* optional */ }
|
|
474
344
|
console.error('');
|
|
475
|
-
console.error(' \x1b[38;5;208m●\x1b[0m openzoo — this
|
|
345
|
+
console.error(' \x1b[38;5;208m●\x1b[0m openzoo — this Claude Code session routes through the zoo');
|
|
476
346
|
console.error(` endpoint : ${base}`);
|
|
477
|
-
console.error(' models : GET /v1/models (quoteable
|
|
347
|
+
console.error(' models : GET /v1/models (quoteable ids only — Claude /model is a short picker + Auto)');
|
|
478
348
|
console.error(' auth : gateway token (ANTHROPIC_API_KEY unset — no api.anthropic.com billing)');
|
|
479
349
|
if (wallet) console.error(` wallet : ${wallet}`);
|
|
480
350
|
console.error(' spend : live in the status line below (bottom of screen); receipts in ~/.openzoo/proxy.log');
|
|
481
351
|
console.error(' every turn pays x402.');
|
|
482
352
|
console.error('');
|
|
483
|
-
const child = spawn(
|
|
353
|
+
const child = spawn(cli, rest, { stdio: 'inherit', env });
|
|
484
354
|
child.on('exit', (c) => { try { restoreStatus?.(); } catch { /* ignore */ } process.exit(c ?? 0); });
|
|
485
|
-
child.on('error', (e) => { console.error(`openzoo: could not launch
|
|
355
|
+
child.on('error', (e) => { console.error(`openzoo: could not launch claude: ${e.message}`); process.exit(1); });
|
|
486
356
|
return;
|
|
487
357
|
}
|
|
488
358
|
|
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;
|