openzoo 0.49.8 → 0.49.9
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 +26 -7
- package/bin/openzoo.js +3 -2
- package/lib/claudecode.js +847 -0
- package/lib/grokui.mjs +1323 -225
- package/lib/launch.js +220 -91
- package/lib/livestatus.js +4 -2
- package/lib/modelroute/README.md +1 -0
- package/lib/modelroute/catalog.json +1 -0
- package/lib/modelroute/outcomes.json +1566 -0
- package/lib/modelroute/router.json +1 -0
- package/lib/modelroute.js +737 -0
- package/lib/models.js +155 -64
- package/lib/package.json +3 -0
- package/lib/podagent.mjs +73 -27
- package/lib/proxy.js +106 -46
- package/lib/relay.js +275 -0
- package/lib/runguard.js +31 -0
- package/lib/spill.js +9 -1
- package/lib/think.js +126 -0
- package/package.json +5 -4
- package/vendor/modelroute/CURRENT_STATE.md +132 -0
- package/vendor/modelroute/HANDOFF.md +159 -0
- package/vendor/modelroute/catalog.json +1 -0
- package/vendor/modelroute/holographic_modelroute.py +809 -0
- package/vendor/modelroute/outcomes.json +1566 -0
- package/vendor/modelroute/router.json +1 -0
package/lib/proxy.js
CHANGED
|
@@ -20,7 +20,11 @@ import {
|
|
|
20
20
|
corpusRecall,
|
|
21
21
|
decideChatSpill, isOneShotCorpusAsk,
|
|
22
22
|
} from './spill.js';
|
|
23
|
-
import { rewritablePath, modelsListForRequest,
|
|
23
|
+
import { rewritablePath, modelsListForRequest, isHarnessAliasId, rewriteChatModel, zooModelIds, CLASSIFY_MAX_TOKENS, raiseReasoningMaxTokens, isAutoModel } from './models.js';
|
|
24
|
+
import {
|
|
25
|
+
route as routeTask, routeChatBody, fallbackChain, isRetryableStatus,
|
|
26
|
+
outcomeFromResponse, recordRouteOutcome, autoModelListEntry,
|
|
27
|
+
} from './modelroute.js';
|
|
24
28
|
import { forgetContext } from './contexts.js';
|
|
25
29
|
import { injectBrief } from './brief.js';
|
|
26
30
|
import { withNamespace } from './namespace.js';
|
|
@@ -33,6 +37,7 @@ import { priceHoldings } from './livestatus.js';
|
|
|
33
37
|
import { receiptUsedCogs, receiptDirectUsd, pairActualBilled } from './racesettle.js';
|
|
34
38
|
import { rewriteWrapClientError } from './wrap.js';
|
|
35
39
|
import { fetchHeaders } from './fetch.js';
|
|
40
|
+
import { relay } from './relay.js';
|
|
36
41
|
|
|
37
42
|
const HOP_BY_HOP = new Set([
|
|
38
43
|
'host', 'connection', 'keep-alive', 'transfer-encoding', 'upgrade',
|
|
@@ -82,46 +87,6 @@ async function readBody(req) {
|
|
|
82
87
|
return Buffer.concat(chunks);
|
|
83
88
|
}
|
|
84
89
|
|
|
85
|
-
/** Pipe an upstream fetch Response to the client, unbuffered (SSE-safe).
|
|
86
|
-
*
|
|
87
|
-
* `onReceipt` is called with the gateway's x402 block when it arrives. On a
|
|
88
|
-
* STREAMED call there is no JSON body to carry that block, so the gateway
|
|
89
|
-
* emits it as an SSE COMMENT (`: x402 {...}`) after the last frame — comments
|
|
90
|
-
* are discarded by every compliant client, so nothing downstream sees it, but
|
|
91
|
-
* without reading it here every spend and savings figure on the status line
|
|
92
|
-
* would silently read zero the moment real streaming was switched on.
|
|
93
|
-
*
|
|
94
|
-
* Sniffing NEVER delays a byte: each chunk is written to the client first and
|
|
95
|
-
* only then scanned. */
|
|
96
|
-
function relay(res, upstream, onReceipt) {
|
|
97
|
-
const headers = {};
|
|
98
|
-
upstream.headers.forEach((v, k) => {
|
|
99
|
-
if (!['transfer-encoding', 'connection', 'content-encoding', 'content-length'].includes(k)) headers[k] = v;
|
|
100
|
-
});
|
|
101
|
-
res.writeHead(upstream.status, headers);
|
|
102
|
-
if (!upstream.body) { res.end(); return Promise.resolve(); }
|
|
103
|
-
const sse = (upstream.headers.get('content-type') || '').includes('text/event-stream');
|
|
104
|
-
return new Promise((resolve) => {
|
|
105
|
-
const body = Readable.fromWeb(upstream.body);
|
|
106
|
-
body.on('error', () => res.destroy());
|
|
107
|
-
res.on('close', () => body.destroy());
|
|
108
|
-
body.on('end', resolve);
|
|
109
|
-
if (!sse || typeof onReceipt !== 'function') { body.pipe(res); return; }
|
|
110
|
-
let pending = '';
|
|
111
|
-
body.on('data', (c) => {
|
|
112
|
-
res.write(c);
|
|
113
|
-
pending += c.toString('utf8');
|
|
114
|
-
const lines = pending.split('\n');
|
|
115
|
-
pending = lines.pop() ?? ''; // a comment can straddle two chunks
|
|
116
|
-
for (const line of lines) {
|
|
117
|
-
if (!line.startsWith(': x402 ')) continue;
|
|
118
|
-
try { onReceipt(JSON.parse(line.slice(7))); } catch { /* not our frame */ }
|
|
119
|
-
}
|
|
120
|
-
});
|
|
121
|
-
body.on('end', () => res.end());
|
|
122
|
-
});
|
|
123
|
-
}
|
|
124
|
-
|
|
125
90
|
function jsonErr(res, status, message, extraFields = {}) {
|
|
126
91
|
res.writeHead(status, { 'content-type': 'application/json' });
|
|
127
92
|
res.end(JSON.stringify({ error: { message }, ...extraFields }));
|
|
@@ -814,6 +779,9 @@ async function maybeCacheCorpus(req, bodyBuf, log, stats, extra = {}) {
|
|
|
814
779
|
* default, so localhost behaviour is unchanged.
|
|
815
780
|
*/
|
|
816
781
|
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;
|
|
817
785
|
const client = new PayClient();
|
|
818
786
|
const log = silent ? () => {} : (...a) => console.log(...a);
|
|
819
787
|
// ALWAYS-ON. `silent: true` is used by the editor path to keep startup tidy,
|
|
@@ -1197,6 +1165,35 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
|
|
|
1197
1165
|
return;
|
|
1198
1166
|
}
|
|
1199
1167
|
|
|
1168
|
+
// Local decision � no upstream, no payment. Same keys as Python route().
|
|
1169
|
+
{
|
|
1170
|
+
const routePath = (req.url || '').split('?')[0];
|
|
1171
|
+
if (req.method === 'POST' && (routePath === '/route' || routePath === '/v1/route')) {
|
|
1172
|
+
let payload;
|
|
1173
|
+
try { payload = JSON.parse(bodyBuf.toString('utf8') || '{}'); } catch {
|
|
1174
|
+
jsonErr(res, 400, 'invalid /route body');
|
|
1175
|
+
return;
|
|
1176
|
+
}
|
|
1177
|
+
try {
|
|
1178
|
+
const r = routeTask(payload.text ?? '', {
|
|
1179
|
+
allow_free: payload.allow_free ?? false,
|
|
1180
|
+
bindable: payload.bindable ?? true,
|
|
1181
|
+
context: payload.context,
|
|
1182
|
+
input_tokens: payload.input_tokens,
|
|
1183
|
+
has_image: payload.has_image,
|
|
1184
|
+
needs_tools: payload.needs_tools,
|
|
1185
|
+
needs_json: payload.needs_json,
|
|
1186
|
+
...(payload.constraints && typeof payload.constraints === 'object' ? payload.constraints : {}),
|
|
1187
|
+
});
|
|
1188
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
1189
|
+
res.end(JSON.stringify(r));
|
|
1190
|
+
} catch (err) {
|
|
1191
|
+
jsonErr(res, 500, `openzoo /route: ${err.message}`);
|
|
1192
|
+
}
|
|
1193
|
+
return;
|
|
1194
|
+
}
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1200
1197
|
// ANTHROPIC MESSAGES SHAPE. A harness pointed here via ANTHROPIC_BASE_URL
|
|
1201
1198
|
// (Claude Code, the Anthropic SDKs) speaks POST /v1/messages, not chat
|
|
1202
1199
|
// completions — this is how such a harness routes its inference through
|
|
@@ -1299,6 +1296,7 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
|
|
|
1299
1296
|
// carries a model field, not just chat/completions, so /completions,
|
|
1300
1297
|
// /responses and future shapes all work. Never silent.
|
|
1301
1298
|
let wantsStream = false;
|
|
1299
|
+
let autoRoute = null;
|
|
1302
1300
|
if ((req.url || '').includes('/chat/completions') && req.method === 'POST') {
|
|
1303
1301
|
servedRequests += 1;
|
|
1304
1302
|
say(`\n<- request #${servedRequests} from ${(req.headers['user-agent'] || 'unknown').slice(0, 40)}`);
|
|
@@ -1332,6 +1330,26 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
|
|
|
1332
1330
|
try { ids = await zooModelIds(); } catch { /* catalog miss: still skip the floor on a tiny classify */ }
|
|
1333
1331
|
const policy = rewriteChatModel(parsed, ids, { bodyLen: bodyBuf.length });
|
|
1334
1332
|
parsed = policy.parsed;
|
|
1333
|
+
if (!policy.tiny && (policy.auto || isAutoModel(parsed?.model))) {
|
|
1334
|
+
autoRoute = routeChatBody(parsed, {
|
|
1335
|
+
allow_free: false,
|
|
1336
|
+
bindable: true,
|
|
1337
|
+
// Live quoteable ids only � Auto must never emit :batch / $0 /
|
|
1338
|
+
// missing-price rows that 500 `bad openrouter price` on Fly.
|
|
1339
|
+
...(ids.length ? { allow_ids: ids } : {}),
|
|
1340
|
+
});
|
|
1341
|
+
if (!autoRoute.model) {
|
|
1342
|
+
jsonErr(res, 422, autoRoute.reason || 'openzoo/auto: no feasible model', { route: autoRoute });
|
|
1343
|
+
return;
|
|
1344
|
+
}
|
|
1345
|
+
parsed = { ...parsed, model: autoRoute.model };
|
|
1346
|
+
const bump = raiseReasoningMaxTokens(parsed);
|
|
1347
|
+
parsed = bump.parsed;
|
|
1348
|
+
say(`openzoo/auto -> ${autoRoute.model} p=${autoRoute.p_success} cleared=${autoRoute.cleared_bar} ${autoRoute.task_class}${autoRoute.bind_first ? ' bind_first' : ''}`);
|
|
1349
|
+
if (!autoRoute.cleared_bar) {
|
|
1350
|
+
say(`openzoo/auto cleared_bar=false � strongest fallback, not a normal pick`);
|
|
1351
|
+
}
|
|
1352
|
+
}
|
|
1335
1353
|
if (policy.tiny) {
|
|
1336
1354
|
// `openzoo claude` starts us silent � `log` is a no-op then. say()
|
|
1337
1355
|
// is the proxy.log channel and never the Claude Code TTY.
|
|
@@ -1381,7 +1399,7 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
|
|
|
1381
1399
|
log(`reasoning model ${parsed.model}: max_tokens ${policy.raisedFrom} -> ${policy.raisedTo} (thinking shares the budget; OPENZOO_REASONING_MAX_TOKENS_X=1 disables)`);
|
|
1382
1400
|
}
|
|
1383
1401
|
}
|
|
1384
|
-
if (policy.tiny || policy.raised || (policy.to && policy.to !== policy.from)) {
|
|
1402
|
+
if (policy.tiny || policy.raised || autoRoute || (policy.to && policy.to !== policy.from)) {
|
|
1385
1403
|
bodyBuf = Buffer.from(JSON.stringify(parsed));
|
|
1386
1404
|
}
|
|
1387
1405
|
// Tell the agent what it is actually connected to — in band, where it
|
|
@@ -1508,9 +1526,9 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
|
|
|
1508
1526
|
const response = await fetchHeaders(url, init);
|
|
1509
1527
|
if (response.ok) {
|
|
1510
1528
|
const payload = await response.json();
|
|
1511
|
-
//
|
|
1512
|
-
//
|
|
1513
|
-
//
|
|
1529
|
+
// Quoteable catalog only. Raw OpenRouter dump includes :batch,
|
|
1530
|
+
// $0 / missing prices, and we used to mint openzoo-* twins of each
|
|
1531
|
+
// � Claude Code /model then showed 63 clones and Auto 500'd.
|
|
1514
1532
|
res.writeHead(200, { 'content-type': 'application/json' });
|
|
1515
1533
|
res.end(JSON.stringify(modelsListForRequest(payload, req.headers)));
|
|
1516
1534
|
return;
|
|
@@ -1522,7 +1540,12 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
|
|
|
1522
1540
|
return;
|
|
1523
1541
|
}
|
|
1524
1542
|
const probe = req.method === 'GET' && /^\/v1\/models\/(.+)$/.exec(path);
|
|
1525
|
-
if (probe &&
|
|
1543
|
+
if (probe && isAutoModel(decodeURIComponent(probe[1]))) {
|
|
1544
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
1545
|
+
res.end(JSON.stringify(autoModelListEntry()));
|
|
1546
|
+
return;
|
|
1547
|
+
}
|
|
1548
|
+
if (probe && isHarnessAliasId(decodeURIComponent(probe[1]))) {
|
|
1526
1549
|
res.writeHead(200, { 'content-type': 'application/json' });
|
|
1527
1550
|
res.end(JSON.stringify({ id: decodeURIComponent(probe[1]), object: 'model', owned_by: 'openzoo-alias' }));
|
|
1528
1551
|
return;
|
|
@@ -1592,6 +1615,32 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
|
|
|
1592
1615
|
} else {
|
|
1593
1616
|
result = await client.fetch(url, init);
|
|
1594
1617
|
}
|
|
1618
|
+
// Walk the auto shortlist on 429/5xx � cheapest-first among models that
|
|
1619
|
+
// cleared the bar. Finite: one pass over fallbackChain(), never a loop.
|
|
1620
|
+
if (autoRoute?.cleared_bar && isRetryableStatus(result.response.status)) {
|
|
1621
|
+
const chain = fallbackChain(autoRoute);
|
|
1622
|
+
for (const next of chain) {
|
|
1623
|
+
const failed = result.response.status;
|
|
1624
|
+
try { await result.response.arrayBuffer(); } catch { /* drain */ }
|
|
1625
|
+
say(`openzoo/auto fallback HTTP ${failed} -> ${next}`);
|
|
1626
|
+
const rewriteModel = (buf) => {
|
|
1627
|
+
try {
|
|
1628
|
+
const b = JSON.parse(buf.toString('utf8'));
|
|
1629
|
+
b.model = next;
|
|
1630
|
+
return Buffer.from(JSON.stringify(b));
|
|
1631
|
+
} catch { return buf; }
|
|
1632
|
+
};
|
|
1633
|
+
bodyBuf = rewriteModel(bodyBuf);
|
|
1634
|
+
init.body = bodyBuf;
|
|
1635
|
+
if (cached) {
|
|
1636
|
+
cached = { ...cached, body: rewriteModel(cached.body) };
|
|
1637
|
+
result = await send(cached.body, cached.contextId, cached.topK, corpusCharsForSend(boundChars, cached.contextId, cached.corpus?.length));
|
|
1638
|
+
} else {
|
|
1639
|
+
result = await client.fetch(url, { ...init, body: bodyBuf });
|
|
1640
|
+
}
|
|
1641
|
+
if (!isRetryableStatus(result.response.status)) break;
|
|
1642
|
+
}
|
|
1643
|
+
}
|
|
1595
1644
|
const { response, paid, receipt } = result;
|
|
1596
1645
|
if (paid && receipt) {
|
|
1597
1646
|
if (receipt.ok && typeof receipt.billedUsd === 'number') {
|
|
@@ -1636,6 +1685,13 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
|
|
|
1636
1685
|
// that contract ourselves. An upstream that someday truly streams (SSE
|
|
1637
1686
|
// content-type) passes straight through the relay below, untouched.
|
|
1638
1687
|
const upCt = response.headers.get('content-type') || '';
|
|
1688
|
+
const noteAutoOutcome = (status, data, streamed = false) => {
|
|
1689
|
+
if (!autoRoute) return;
|
|
1690
|
+
let ok = outcomeFromResponse(status, data);
|
|
1691
|
+
if (ok == null) return;
|
|
1692
|
+
if (streamed && status >= 200 && status < 300) ok = true;
|
|
1693
|
+
recordRouteOutcome(autoRoute, ok);
|
|
1694
|
+
};
|
|
1639
1695
|
if (isChat && response.ok && upCt.includes('application/json')) {
|
|
1640
1696
|
let data = null;
|
|
1641
1697
|
try { data = await response.clone().json(); } catch { /* not JSON after all */ }
|
|
@@ -1694,6 +1750,7 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
|
|
|
1694
1750
|
say(`credit -> $${x.billedUsd.toFixed(6)} · session $${sessionSpent.toFixed(6)}`);
|
|
1695
1751
|
}
|
|
1696
1752
|
if (data?.object === 'chat.completion') {
|
|
1753
|
+
noteAutoOutcome(response.status, data);
|
|
1697
1754
|
if (rKey) replayPut(rKey, data, response.headers.get('x-payment-response'));
|
|
1698
1755
|
// Anthropic-shaped caller gets an Anthropic-shaped answer, streamed
|
|
1699
1756
|
// or not, so Claude Code and the SDKs parse it natively.
|
|
@@ -1792,6 +1849,9 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
|
|
|
1792
1849
|
return;
|
|
1793
1850
|
}
|
|
1794
1851
|
|
|
1852
|
+
if (autoRoute) {
|
|
1853
|
+
noteAutoOutcome(response.status, null, (upCt.includes('text/event-stream') && response.ok));
|
|
1854
|
+
}
|
|
1795
1855
|
await relay(res, response, meterStreamed);
|
|
1796
1856
|
} catch (err) {
|
|
1797
1857
|
if (err instanceof QuoteTooHighError) {
|
package/lib/relay.js
ADDED
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pipe an upstream fetch Response to the client.
|
|
3
|
+
*
|
|
4
|
+
* Fly (and other CDNs) gzip error bodies. Node fetch does not always inflate
|
|
5
|
+
* those — MEASURED: a 400 from x402-tokens.fly.dev arrived still gzip-wrapped
|
|
6
|
+
* with `Content-Encoding: gzip`. The old relay stripped that header and
|
|
7
|
+
* `Content-Length`, then piped `response.body` RAW. Claude Code printed
|
|
8
|
+
* `API Error: 400` plus diamond/binary mojibake.
|
|
9
|
+
*
|
|
10
|
+
* Contract: never emit a compressed body without Content-Encoding. Prefer
|
|
11
|
+
* decompressing so the client sees application/json or text (Claude CLI can
|
|
12
|
+
* parse the error). Unknown encodings keep the header and the bytes together.
|
|
13
|
+
*/
|
|
14
|
+
import zlib from 'node:zlib';
|
|
15
|
+
import { promisify } from 'node:util';
|
|
16
|
+
import { Readable, PassThrough } from 'node:stream';
|
|
17
|
+
|
|
18
|
+
const gunzipAsync = promisify(zlib.gunzip);
|
|
19
|
+
const inflateAsync = promisify(zlib.inflate);
|
|
20
|
+
const inflateRawAsync = promisify(zlib.inflateRaw);
|
|
21
|
+
const brotliAsync = promisify(zlib.brotliDecompress);
|
|
22
|
+
|
|
23
|
+
const HOP = new Set(['transfer-encoding', 'connection', 'content-length']);
|
|
24
|
+
|
|
25
|
+
export function looksGzip(buf) {
|
|
26
|
+
return Buffer.isBuffer(buf) && buf.length >= 2 && buf[0] === 0x1f && buf[1] === 0x8b;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function headerEncoding(headers) {
|
|
30
|
+
return String(headers?.get?.('content-encoding') || '').trim();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Single known codec, or null if identity / absent / unknown / stacked. */
|
|
34
|
+
export function knownCodec(encoding) {
|
|
35
|
+
const parts = String(encoding || '')
|
|
36
|
+
.split(',')
|
|
37
|
+
.map((s) => s.trim().toLowerCase())
|
|
38
|
+
.filter((s) => s && s !== 'identity');
|
|
39
|
+
if (parts.length !== 1) return null;
|
|
40
|
+
if (parts[0] === 'gzip' || parts[0] === 'x-gzip') return 'gzip';
|
|
41
|
+
if (parts[0] === 'deflate') return 'deflate';
|
|
42
|
+
if (parts[0] === 'br') return 'br';
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export async function inflateEncoded(buf, encoding) {
|
|
47
|
+
const codec = knownCodec(encoding) || (looksGzip(buf) ? 'gzip' : null);
|
|
48
|
+
if (!codec) return buf;
|
|
49
|
+
// Leftover Content-Encoding after fetch already inflated: do not gunzip JSON.
|
|
50
|
+
if (codec === 'gzip' && !looksGzip(buf)) return buf;
|
|
51
|
+
try {
|
|
52
|
+
if (codec === 'gzip') return await gunzipAsync(buf);
|
|
53
|
+
if (codec === 'br') return await brotliAsync(buf);
|
|
54
|
+
try {
|
|
55
|
+
return await inflateAsync(buf);
|
|
56
|
+
} catch {
|
|
57
|
+
return await inflateRawAsync(buf);
|
|
58
|
+
}
|
|
59
|
+
} catch (err) {
|
|
60
|
+
if (!looksBinary(buf)) return buf;
|
|
61
|
+
throw err;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function looksBinary(buf) {
|
|
66
|
+
if (!buf || !buf.length) return false;
|
|
67
|
+
if (looksGzip(buf)) return true;
|
|
68
|
+
const n = Math.min(buf.length, 256);
|
|
69
|
+
let ctrl = 0;
|
|
70
|
+
for (let i = 0; i < n; i++) {
|
|
71
|
+
const c = buf[i];
|
|
72
|
+
if (c === 0 || c < 8 || (c > 13 && c < 32)) ctrl += 1;
|
|
73
|
+
}
|
|
74
|
+
return ctrl >= 4;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function copyHeaders(upstream, { decoded }) {
|
|
78
|
+
const headers = {};
|
|
79
|
+
upstream.headers.forEach((v, k) => {
|
|
80
|
+
const key = String(k).toLowerCase();
|
|
81
|
+
if (HOP.has(key)) return;
|
|
82
|
+
// Drop Content-Encoding only when the bytes we send are already inflated.
|
|
83
|
+
if (decoded && key === 'content-encoding') return;
|
|
84
|
+
headers[k] = v;
|
|
85
|
+
});
|
|
86
|
+
return headers;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function preferReadableType(headers, buf) {
|
|
90
|
+
const ct = String(headers['content-type'] || '');
|
|
91
|
+
if (ct && !/octet-stream|(?:x-)?gzip|br\b/i.test(ct)) return;
|
|
92
|
+
const c = buf[0];
|
|
93
|
+
headers['content-type'] = (c === 0x7b || c === 0x5b)
|
|
94
|
+
? 'application/json; charset=utf-8'
|
|
95
|
+
: 'text/plain; charset=utf-8';
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function jsonUpstreamErr(res, status) {
|
|
99
|
+
res.writeHead(status || 502, { 'content-type': 'application/json' });
|
|
100
|
+
res.end(JSON.stringify({ error: { message: `upstream HTTP ${status || 502}` } }));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async function readBody(upstream) {
|
|
104
|
+
if (!upstream.body) return Buffer.alloc(0);
|
|
105
|
+
const chunks = [];
|
|
106
|
+
for await (const c of Readable.fromWeb(upstream.body)) chunks.push(c);
|
|
107
|
+
return Buffer.concat(chunks);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Non-OK: buffer, inflate gzip/br/deflate (or gzip-magic with no header),
|
|
112
|
+
* send uncompressed JSON/text. Never forward gzip bytes with the encoding
|
|
113
|
+
* header stripped. Un-decodable compressed bodies become a short JSON error
|
|
114
|
+
* so Claude / grokui do not paint diamond mojibake — the canvas fold to
|
|
115
|
+
* `upstream HTTP 400` lives in #65; we just refuse to emit the binary.
|
|
116
|
+
*/
|
|
117
|
+
async function relayError(res, upstream) {
|
|
118
|
+
const encoding = headerEncoding(upstream.headers);
|
|
119
|
+
const codec = knownCodec(encoding);
|
|
120
|
+
let buf;
|
|
121
|
+
try {
|
|
122
|
+
buf = await readBody(upstream);
|
|
123
|
+
} catch {
|
|
124
|
+
jsonUpstreamErr(res, upstream.status);
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (codec || looksGzip(buf)) {
|
|
129
|
+
try {
|
|
130
|
+
buf = await inflateEncoded(buf, encoding);
|
|
131
|
+
} catch {
|
|
132
|
+
jsonUpstreamErr(res, upstream.status);
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
if (looksBinary(buf)) {
|
|
136
|
+
jsonUpstreamErr(res, upstream.status);
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
const headers = copyHeaders(upstream, { decoded: true });
|
|
140
|
+
preferReadableType(headers, buf);
|
|
141
|
+
res.writeHead(upstream.status, headers);
|
|
142
|
+
res.end(buf);
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (encoding && !codec) {
|
|
147
|
+
// Unknown / stacked coding: keep the label with the bytes.
|
|
148
|
+
res.writeHead(upstream.status, copyHeaders(upstream, { decoded: false }));
|
|
149
|
+
res.end(buf);
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (looksBinary(buf)) {
|
|
154
|
+
jsonUpstreamErr(res, upstream.status);
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
const headers = copyHeaders(upstream, { decoded: true });
|
|
158
|
+
preferReadableType(headers, buf);
|
|
159
|
+
res.writeHead(upstream.status, headers);
|
|
160
|
+
res.end(buf);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function createDecoder(codec) {
|
|
164
|
+
if (codec === 'gzip') return zlib.createGunzip();
|
|
165
|
+
if (codec === 'deflate') return zlib.createInflate();
|
|
166
|
+
if (codec === 'br') return zlib.createBrotliDecompress();
|
|
167
|
+
return null;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function prependChunk(chunk, rest) {
|
|
171
|
+
const out = new PassThrough();
|
|
172
|
+
out.write(chunk);
|
|
173
|
+
rest.pipe(out);
|
|
174
|
+
return out;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function attachBody(res, body, onReceipt, sse, resolve) {
|
|
178
|
+
body.on('error', () => res.destroy());
|
|
179
|
+
res.on('close', () => body.destroy());
|
|
180
|
+
if (!sse || typeof onReceipt !== 'function') {
|
|
181
|
+
body.pipe(res);
|
|
182
|
+
body.on('end', resolve);
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
let pending = '';
|
|
186
|
+
body.on('data', (c) => {
|
|
187
|
+
res.write(c);
|
|
188
|
+
pending += c.toString('utf8');
|
|
189
|
+
const lines = pending.split('\n');
|
|
190
|
+
pending = lines.pop() ?? '';
|
|
191
|
+
for (const line of lines) {
|
|
192
|
+
if (!line.startsWith(': x402 ')) continue;
|
|
193
|
+
try { onReceipt(JSON.parse(line.slice(7))); } catch { /* not our frame */ }
|
|
194
|
+
}
|
|
195
|
+
});
|
|
196
|
+
body.on('end', () => { res.end(); resolve(); });
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* 2xx stream. Uncompressed (the cheap Fly 200) is the old pipe. Encoded
|
|
201
|
+
* bodies are inflated when we know the codec, otherwise the encoding
|
|
202
|
+
* header stays with the bytes.
|
|
203
|
+
*/
|
|
204
|
+
function relayOk(res, upstream, onReceipt) {
|
|
205
|
+
const encoding = headerEncoding(upstream.headers);
|
|
206
|
+
const codec = knownCodec(encoding);
|
|
207
|
+
const sse = (upstream.headers.get('content-type') || '').includes('text/event-stream');
|
|
208
|
+
|
|
209
|
+
return new Promise((resolve) => {
|
|
210
|
+
const raw = Readable.fromWeb(upstream.body);
|
|
211
|
+
raw.on('error', () => res.destroy());
|
|
212
|
+
res.on('close', () => raw.destroy());
|
|
213
|
+
|
|
214
|
+
if (!encoding) {
|
|
215
|
+
res.writeHead(upstream.status, copyHeaders(upstream, { decoded: true }));
|
|
216
|
+
attachBody(res, raw, onReceipt, sse, resolve);
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Peek so a leftover Content-Encoding on already-plain bytes is stripped,
|
|
221
|
+
// and a real gzip body is never sent naked.
|
|
222
|
+
let decided = false;
|
|
223
|
+
raw.once('data', (chunk) => {
|
|
224
|
+
if (decided) return;
|
|
225
|
+
decided = true;
|
|
226
|
+
const head = Buffer.from(chunk);
|
|
227
|
+
const gzip = looksGzip(head);
|
|
228
|
+
if (gzip || (codec && codec !== 'gzip')) {
|
|
229
|
+
const dec = codec ? createDecoder(codec) : zlib.createGunzip();
|
|
230
|
+
if (!dec) {
|
|
231
|
+
res.writeHead(upstream.status, copyHeaders(upstream, { decoded: false }));
|
|
232
|
+
attachBody(res, prependChunk(head, raw), onReceipt, sse, resolve);
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
dec.write(head);
|
|
236
|
+
raw.pipe(dec);
|
|
237
|
+
res.writeHead(upstream.status, copyHeaders(upstream, { decoded: true }));
|
|
238
|
+
attachBody(res, dec, onReceipt, sse, resolve);
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
// Header leftover on plain JSON/SSE — drop encoding, replay the peek.
|
|
242
|
+
res.writeHead(upstream.status, copyHeaders(upstream, { decoded: true }));
|
|
243
|
+
attachBody(res, prependChunk(head, raw), onReceipt, sse, resolve);
|
|
244
|
+
});
|
|
245
|
+
raw.once('end', () => {
|
|
246
|
+
if (decided) return;
|
|
247
|
+
decided = true;
|
|
248
|
+
res.writeHead(upstream.status, copyHeaders(upstream, { decoded: true }));
|
|
249
|
+
res.end();
|
|
250
|
+
resolve();
|
|
251
|
+
});
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** Pipe an upstream fetch Response to the client, unbuffered (SSE-safe).
|
|
256
|
+
*
|
|
257
|
+
* `onReceipt` is called with the gateway's x402 block when it arrives. On a
|
|
258
|
+
* STREAMED call there is no JSON body to carry that block, so the gateway
|
|
259
|
+
* emits it as an SSE COMMENT (`: x402 {...}`) after the last frame —
|
|
260
|
+
* comments are discarded by every compliant client, so nothing downstream
|
|
261
|
+
* sees it, but without reading it here every spend and savings figure on
|
|
262
|
+
* the status line would silently read zero the moment real streaming was
|
|
263
|
+
* switched on.
|
|
264
|
+
*
|
|
265
|
+
* Sniffing NEVER delays a byte: each chunk is written to the client first
|
|
266
|
+
* and only then scanned. */
|
|
267
|
+
export function relay(res, upstream, onReceipt) {
|
|
268
|
+
if (!upstream.body) {
|
|
269
|
+
res.writeHead(upstream.status, copyHeaders(upstream, { decoded: true }));
|
|
270
|
+
res.end();
|
|
271
|
+
return Promise.resolve();
|
|
272
|
+
}
|
|
273
|
+
if (!upstream.ok) return relayError(res, upstream);
|
|
274
|
+
return relayOk(res, upstream, onReceipt);
|
|
275
|
+
}
|
package/lib/runguard.js
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/** Pin find(1) to the thread cwd. Unbounded `find /` is a full-disk walk. */
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
export const FIND_MAXDEPTH = Number(process.env.OZ_FIND_MAXDEPTH || 8);
|
|
5
|
+
|
|
6
|
+
export function guardFindCwd(command, cwd = '.') {
|
|
7
|
+
if (typeof command !== 'string' || !command) return command;
|
|
8
|
+
if (!/(?:^|[\s;|&(`])find(?:\s|$)/.test(command)) return command;
|
|
9
|
+
const root = path.resolve(cwd || '.');
|
|
10
|
+
return command.replace(/(^|[\s;|&(`])((?:sudo\s+)?)find(\s+)([^;&|`\n]*)/g, (m, pre, sudo, sp, args) => {
|
|
11
|
+
const tokens = args.match(/"[^"]*"|'[^']*'|\S+/g) || [];
|
|
12
|
+
const paths = [];
|
|
13
|
+
let i = 0;
|
|
14
|
+
while (i < tokens.length && !String(tokens[i]).startsWith('-')) {
|
|
15
|
+
paths.push(tokens[i]);
|
|
16
|
+
i++;
|
|
17
|
+
}
|
|
18
|
+
const rest = tokens.slice(i);
|
|
19
|
+
const hasMax = rest.some((t) => t === '-maxdepth');
|
|
20
|
+
const pin = (raw) => {
|
|
21
|
+
const unquoted = String(raw).replace(/^['"]|['"]$/g, '');
|
|
22
|
+
if (!unquoted || unquoted === '.' || unquoted === './') return '.';
|
|
23
|
+
const abs = path.resolve(root, unquoted);
|
|
24
|
+
const inside = abs === root || abs.startsWith(root + path.sep);
|
|
25
|
+
return inside ? raw : '.';
|
|
26
|
+
};
|
|
27
|
+
const newPaths = (paths.length ? paths.map(pin) : ['.']);
|
|
28
|
+
const depth = hasMax ? [] : ['-maxdepth', String(FIND_MAXDEPTH)];
|
|
29
|
+
return `${pre}${sudo}find${sp}${[...newPaths, ...depth, ...rest].join(' ')}`;
|
|
30
|
+
});
|
|
31
|
+
}
|
package/lib/spill.js
CHANGED
|
@@ -1916,7 +1916,15 @@ function emptySpillDecision(msgs, reason) {
|
|
|
1916
1916
|
export function decideChatSpill(body, opts = {}) {
|
|
1917
1917
|
const msgs = Array.isArray(body?.messages) ? body.messages : null;
|
|
1918
1918
|
if (!msgs?.length) return emptySpillDecision(msgs, 'no-messages');
|
|
1919
|
-
|
|
1919
|
+
let minChars = opts.minPrefixChars ?? SPILL_MIN_PREFIX_CHARS;
|
|
1920
|
+
const dx = Number(opts.dollarX);
|
|
1921
|
+
const model = String(body?.model || '').trim().toLowerCase();
|
|
1922
|
+
const auto = model === 'openzoo/auto' || model === 'openzoo-auto' || model === 'auto';
|
|
1923
|
+
// Thin HUD / Auto: bind way earlier than 16k. 2.1x session is cheap-model
|
|
1924
|
+
// vs cheap-model because the prefix never cleared the gate.
|
|
1925
|
+
if (auto || (Number.isFinite(dx) && dx > 0 && dx < 5)) {
|
|
1926
|
+
minChars = Math.min(minChars, 2048);
|
|
1927
|
+
}
|
|
1920
1928
|
|
|
1921
1929
|
if (isOneShotCorpusAsk(msgs, minChars)) {
|
|
1922
1930
|
const last = msgs[msgs.length - 1];
|