openzoo 0.49.8 → 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 +40 -7
- package/lib/claudecode.js +231 -0
- package/lib/grokui.mjs +850 -175
- package/lib/launch.js +72 -73
- 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 +191 -62
- package/lib/package.json +3 -0
- package/lib/podagent.mjs +73 -27
- package/lib/proxy.js +141 -7
- package/lib/racesettle.js +23 -2
- package/lib/responses-stream.js +176 -0
- package/lib/runguard.js +31 -0
- package/lib/spill.js +9 -1
- package/lib/think.js +126 -0
- package/package.json +4 -3
- 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,12 +20,17 @@ 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';
|
|
27
31
|
import { anthropicToOpenAI, openAIToAnthropic, streamOpenAIToAnthropic, writeAnthropicSse } from './anthropic.js';
|
|
28
32
|
import { responsesToChat, chatToResponses, writeResponsesSse } from './responses.js';
|
|
33
|
+
import { streamOpenAIToResponses } from './responses-stream.js';
|
|
29
34
|
import { loadSessionSpend, saveSessionSpend } from './session.js';
|
|
30
35
|
import { creditBalance, quotedPrices } from './info.js';
|
|
31
36
|
import { subscriptionPublicView } from './subscription.js';
|
|
@@ -1197,6 +1202,35 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
|
|
|
1197
1202
|
return;
|
|
1198
1203
|
}
|
|
1199
1204
|
|
|
1205
|
+
// Local decision � no upstream, no payment. Same keys as Python route().
|
|
1206
|
+
{
|
|
1207
|
+
const routePath = (req.url || '').split('?')[0];
|
|
1208
|
+
if (req.method === 'POST' && (routePath === '/route' || routePath === '/v1/route')) {
|
|
1209
|
+
let payload;
|
|
1210
|
+
try { payload = JSON.parse(bodyBuf.toString('utf8') || '{}'); } catch {
|
|
1211
|
+
jsonErr(res, 400, 'invalid /route body');
|
|
1212
|
+
return;
|
|
1213
|
+
}
|
|
1214
|
+
try {
|
|
1215
|
+
const r = routeTask(payload.text ?? '', {
|
|
1216
|
+
allow_free: payload.allow_free ?? false,
|
|
1217
|
+
bindable: payload.bindable ?? true,
|
|
1218
|
+
context: payload.context,
|
|
1219
|
+
input_tokens: payload.input_tokens,
|
|
1220
|
+
has_image: payload.has_image,
|
|
1221
|
+
needs_tools: payload.needs_tools,
|
|
1222
|
+
needs_json: payload.needs_json,
|
|
1223
|
+
...(payload.constraints && typeof payload.constraints === 'object' ? payload.constraints : {}),
|
|
1224
|
+
});
|
|
1225
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
1226
|
+
res.end(JSON.stringify(r));
|
|
1227
|
+
} catch (err) {
|
|
1228
|
+
jsonErr(res, 500, `openzoo /route: ${err.message}`);
|
|
1229
|
+
}
|
|
1230
|
+
return;
|
|
1231
|
+
}
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1200
1234
|
// ANTHROPIC MESSAGES SHAPE. A harness pointed here via ANTHROPIC_BASE_URL
|
|
1201
1235
|
// (Claude Code, the Anthropic SDKs) speaks POST /v1/messages, not chat
|
|
1202
1236
|
// completions — this is how such a harness routes its inference through
|
|
@@ -1244,7 +1278,11 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
|
|
|
1244
1278
|
}
|
|
1245
1279
|
responsesModel = inbound.model;
|
|
1246
1280
|
const meta = {};
|
|
1247
|
-
|
|
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));
|
|
1248
1286
|
responsesCustom = meta.custom;
|
|
1249
1287
|
responsesMode = true;
|
|
1250
1288
|
req.url = '/v1/chat/completions';
|
|
@@ -1299,6 +1337,7 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
|
|
|
1299
1337
|
// carries a model field, not just chat/completions, so /completions,
|
|
1300
1338
|
// /responses and future shapes all work. Never silent.
|
|
1301
1339
|
let wantsStream = false;
|
|
1340
|
+
let autoRoute = null;
|
|
1302
1341
|
if ((req.url || '').includes('/chat/completions') && req.method === 'POST') {
|
|
1303
1342
|
servedRequests += 1;
|
|
1304
1343
|
say(`\n<- request #${servedRequests} from ${(req.headers['user-agent'] || 'unknown').slice(0, 40)}`);
|
|
@@ -1332,6 +1371,26 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
|
|
|
1332
1371
|
try { ids = await zooModelIds(); } catch { /* catalog miss: still skip the floor on a tiny classify */ }
|
|
1333
1372
|
const policy = rewriteChatModel(parsed, ids, { bodyLen: bodyBuf.length });
|
|
1334
1373
|
parsed = policy.parsed;
|
|
1374
|
+
if (!policy.tiny && (policy.auto || isAutoModel(parsed?.model))) {
|
|
1375
|
+
autoRoute = routeChatBody(parsed, {
|
|
1376
|
+
allow_free: false,
|
|
1377
|
+
bindable: true,
|
|
1378
|
+
// Live quoteable ids only � Auto must never emit :batch / $0 /
|
|
1379
|
+
// missing-price rows that 500 `bad openrouter price` on Fly.
|
|
1380
|
+
...(ids.length ? { allow_ids: ids } : {}),
|
|
1381
|
+
});
|
|
1382
|
+
if (!autoRoute.model) {
|
|
1383
|
+
jsonErr(res, 422, autoRoute.reason || 'openzoo/auto: no feasible model', { route: autoRoute });
|
|
1384
|
+
return;
|
|
1385
|
+
}
|
|
1386
|
+
parsed = { ...parsed, model: autoRoute.model };
|
|
1387
|
+
const bump = raiseReasoningMaxTokens(parsed);
|
|
1388
|
+
parsed = bump.parsed;
|
|
1389
|
+
say(`openzoo/auto -> ${autoRoute.model} p=${autoRoute.p_success} cleared=${autoRoute.cleared_bar} ${autoRoute.task_class}${autoRoute.bind_first ? ' bind_first' : ''}`);
|
|
1390
|
+
if (!autoRoute.cleared_bar) {
|
|
1391
|
+
say(`openzoo/auto cleared_bar=false � strongest fallback, not a normal pick`);
|
|
1392
|
+
}
|
|
1393
|
+
}
|
|
1335
1394
|
if (policy.tiny) {
|
|
1336
1395
|
// `openzoo claude` starts us silent � `log` is a no-op then. say()
|
|
1337
1396
|
// is the proxy.log channel and never the Claude Code TTY.
|
|
@@ -1381,7 +1440,7 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
|
|
|
1381
1440
|
log(`reasoning model ${parsed.model}: max_tokens ${policy.raisedFrom} -> ${policy.raisedTo} (thinking shares the budget; OPENZOO_REASONING_MAX_TOKENS_X=1 disables)`);
|
|
1382
1441
|
}
|
|
1383
1442
|
}
|
|
1384
|
-
if (policy.tiny || policy.raised || (policy.to && policy.to !== policy.from)) {
|
|
1443
|
+
if (policy.tiny || policy.raised || autoRoute || (policy.to && policy.to !== policy.from)) {
|
|
1385
1444
|
bodyBuf = Buffer.from(JSON.stringify(parsed));
|
|
1386
1445
|
}
|
|
1387
1446
|
// Tell the agent what it is actually connected to — in band, where it
|
|
@@ -1508,9 +1567,9 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
|
|
|
1508
1567
|
const response = await fetchHeaders(url, init);
|
|
1509
1568
|
if (response.ok) {
|
|
1510
1569
|
const payload = await response.json();
|
|
1511
|
-
//
|
|
1512
|
-
//
|
|
1513
|
-
//
|
|
1570
|
+
// Quoteable catalog only. Raw OpenRouter dump includes :batch,
|
|
1571
|
+
// $0 / missing prices, and we used to mint openzoo-* twins of each
|
|
1572
|
+
// � Claude Code /model then showed 63 clones and Auto 500'd.
|
|
1514
1573
|
res.writeHead(200, { 'content-type': 'application/json' });
|
|
1515
1574
|
res.end(JSON.stringify(modelsListForRequest(payload, req.headers)));
|
|
1516
1575
|
return;
|
|
@@ -1522,7 +1581,12 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
|
|
|
1522
1581
|
return;
|
|
1523
1582
|
}
|
|
1524
1583
|
const probe = req.method === 'GET' && /^\/v1\/models\/(.+)$/.exec(path);
|
|
1525
|
-
if (probe &&
|
|
1584
|
+
if (probe && isAutoModel(decodeURIComponent(probe[1]))) {
|
|
1585
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
1586
|
+
res.end(JSON.stringify(autoModelListEntry()));
|
|
1587
|
+
return;
|
|
1588
|
+
}
|
|
1589
|
+
if (probe && isHarnessAliasId(decodeURIComponent(probe[1]))) {
|
|
1526
1590
|
res.writeHead(200, { 'content-type': 'application/json' });
|
|
1527
1591
|
res.end(JSON.stringify({ id: decodeURIComponent(probe[1]), object: 'model', owned_by: 'openzoo-alias' }));
|
|
1528
1592
|
return;
|
|
@@ -1592,6 +1656,32 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
|
|
|
1592
1656
|
} else {
|
|
1593
1657
|
result = await client.fetch(url, init);
|
|
1594
1658
|
}
|
|
1659
|
+
// Walk the auto shortlist on 429/5xx � cheapest-first among models that
|
|
1660
|
+
// cleared the bar. Finite: one pass over fallbackChain(), never a loop.
|
|
1661
|
+
if (autoRoute?.cleared_bar && isRetryableStatus(result.response.status)) {
|
|
1662
|
+
const chain = fallbackChain(autoRoute);
|
|
1663
|
+
for (const next of chain) {
|
|
1664
|
+
const failed = result.response.status;
|
|
1665
|
+
try { await result.response.arrayBuffer(); } catch { /* drain */ }
|
|
1666
|
+
say(`openzoo/auto fallback HTTP ${failed} -> ${next}`);
|
|
1667
|
+
const rewriteModel = (buf) => {
|
|
1668
|
+
try {
|
|
1669
|
+
const b = JSON.parse(buf.toString('utf8'));
|
|
1670
|
+
b.model = next;
|
|
1671
|
+
return Buffer.from(JSON.stringify(b));
|
|
1672
|
+
} catch { return buf; }
|
|
1673
|
+
};
|
|
1674
|
+
bodyBuf = rewriteModel(bodyBuf);
|
|
1675
|
+
init.body = bodyBuf;
|
|
1676
|
+
if (cached) {
|
|
1677
|
+
cached = { ...cached, body: rewriteModel(cached.body) };
|
|
1678
|
+
result = await send(cached.body, cached.contextId, cached.topK, corpusCharsForSend(boundChars, cached.contextId, cached.corpus?.length));
|
|
1679
|
+
} else {
|
|
1680
|
+
result = await client.fetch(url, { ...init, body: bodyBuf });
|
|
1681
|
+
}
|
|
1682
|
+
if (!isRetryableStatus(result.response.status)) break;
|
|
1683
|
+
}
|
|
1684
|
+
}
|
|
1595
1685
|
const { response, paid, receipt } = result;
|
|
1596
1686
|
if (paid && receipt) {
|
|
1597
1687
|
if (receipt.ok && typeof receipt.billedUsd === 'number') {
|
|
@@ -1636,6 +1726,13 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
|
|
|
1636
1726
|
// that contract ourselves. An upstream that someday truly streams (SSE
|
|
1637
1727
|
// content-type) passes straight through the relay below, untouched.
|
|
1638
1728
|
const upCt = response.headers.get('content-type') || '';
|
|
1729
|
+
const noteAutoOutcome = (status, data, streamed = false) => {
|
|
1730
|
+
if (!autoRoute) return;
|
|
1731
|
+
let ok = outcomeFromResponse(status, data);
|
|
1732
|
+
if (ok == null) return;
|
|
1733
|
+
if (streamed && status >= 200 && status < 300) ok = true;
|
|
1734
|
+
recordRouteOutcome(autoRoute, ok);
|
|
1735
|
+
};
|
|
1639
1736
|
if (isChat && response.ok && upCt.includes('application/json')) {
|
|
1640
1737
|
let data = null;
|
|
1641
1738
|
try { data = await response.clone().json(); } catch { /* not JSON after all */ }
|
|
@@ -1694,6 +1791,7 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
|
|
|
1694
1791
|
say(`credit -> $${x.billedUsd.toFixed(6)} · session $${sessionSpent.toFixed(6)}`);
|
|
1695
1792
|
}
|
|
1696
1793
|
if (data?.object === 'chat.completion') {
|
|
1794
|
+
noteAutoOutcome(response.status, data);
|
|
1697
1795
|
if (rKey) replayPut(rKey, data, response.headers.get('x-payment-response'));
|
|
1698
1796
|
// Anthropic-shaped caller gets an Anthropic-shaped answer, streamed
|
|
1699
1797
|
// or not, so Claude Code and the SDKs parse it natively.
|
|
@@ -1759,6 +1857,39 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
|
|
|
1759
1857
|
say(`credit -> $${x.billedUsd.toFixed(6)} · session $${sessionSpent.toFixed(6)}`);
|
|
1760
1858
|
};
|
|
1761
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
|
+
|
|
1762
1893
|
// ANTHROPIC CLIENTS GET THE SAME STREAM, IN THEIR OWN GRAMMAR.
|
|
1763
1894
|
// Translated frame by frame rather than buffered, so Claude Code sees
|
|
1764
1895
|
// tokens as they are produced instead of nothing until the turn ends.
|
|
@@ -1792,6 +1923,9 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
|
|
|
1792
1923
|
return;
|
|
1793
1924
|
}
|
|
1794
1925
|
|
|
1926
|
+
if (autoRoute) {
|
|
1927
|
+
noteAutoOutcome(response.status, null, (upCt.includes('text/event-stream') && response.ok));
|
|
1928
|
+
}
|
|
1795
1929
|
await relay(res, response, meterStreamed);
|
|
1796
1930
|
} catch (err) {
|
|
1797
1931
|
if (err instanceof QuoteTooHighError) {
|
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;
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenAI chat-completions SSE -> Responses API SSE, frame by frame.
|
|
3
|
+
*
|
|
4
|
+
* WHY: Codex Security pins wire_api=responses and waits ~10s for
|
|
5
|
+
* `response.created`. The previous path buffered the whole chat completion
|
|
6
|
+
* (Opus reasoning can sit silent for longer than that) then dumped the
|
|
7
|
+
* event sequence at the end. Codex reported "connection interrupted".
|
|
8
|
+
*
|
|
9
|
+
* Same shape as streamOpenAIToAnthropic: write headers immediately, ping on
|
|
10
|
+
* silent reasoning chunks, translate content/tool deltas as they arrive.
|
|
11
|
+
*/
|
|
12
|
+
export function streamOpenAIToResponses(res, upstream, requestedModel, custom) {
|
|
13
|
+
const headers = {
|
|
14
|
+
'content-type': 'text/event-stream',
|
|
15
|
+
'cache-control': 'no-cache',
|
|
16
|
+
connection: 'keep-alive',
|
|
17
|
+
'x-accel-buffering': 'no',
|
|
18
|
+
};
|
|
19
|
+
const settle = upstream?.headers?.get?.('x-payment-response');
|
|
20
|
+
if (settle) headers['x-payment-response'] = settle;
|
|
21
|
+
res.writeHead(200, headers);
|
|
22
|
+
|
|
23
|
+
let seq = 0;
|
|
24
|
+
const send = (type, payload = {}) => {
|
|
25
|
+
res.write(`event: ${type}\n`);
|
|
26
|
+
res.write(`data: ${JSON.stringify({ type, sequence_number: seq++, ...payload })}\n\n`);
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const customNames = custom instanceof Set ? custom : new Set(custom || []);
|
|
30
|
+
const responseId = `resp_${Date.now().toString(36)}`;
|
|
31
|
+
const itemId = `msg_${Math.random().toString(36).slice(2, 10)}`;
|
|
32
|
+
const shell = {
|
|
33
|
+
id: responseId,
|
|
34
|
+
object: 'response',
|
|
35
|
+
created_at: Math.floor(Date.now() / 1000),
|
|
36
|
+
status: 'in_progress',
|
|
37
|
+
model: requestedModel,
|
|
38
|
+
output: [],
|
|
39
|
+
output_text: '',
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
// First bytes NOW — do not wait for a content token. Codex's watchdog is ~10s
|
|
43
|
+
// and a reasoning model can think for longer than that before delta.content.
|
|
44
|
+
send('response.created', { response: shell });
|
|
45
|
+
send('response.in_progress', { response: shell });
|
|
46
|
+
|
|
47
|
+
let textStarted = false;
|
|
48
|
+
let text = '';
|
|
49
|
+
const tools = [];
|
|
50
|
+
|
|
51
|
+
const unwrapCustom = (args) => {
|
|
52
|
+
try {
|
|
53
|
+
const o = JSON.parse(args);
|
|
54
|
+
if (o && typeof o.input === 'string') return o.input;
|
|
55
|
+
} catch { /* raw text */ }
|
|
56
|
+
return args;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const ensureText = () => {
|
|
60
|
+
if (textStarted) return;
|
|
61
|
+
textStarted = true;
|
|
62
|
+
send('response.output_item.added', {
|
|
63
|
+
output_index: 0,
|
|
64
|
+
item: { id: itemId, type: 'message', role: 'assistant', status: 'in_progress', content: [] },
|
|
65
|
+
});
|
|
66
|
+
send('response.content_part.added', {
|
|
67
|
+
item_id: itemId, output_index: 0, content_index: 0,
|
|
68
|
+
part: { type: 'output_text', text: '', annotations: [] },
|
|
69
|
+
});
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const toolIndexBase = () => (textStarted ? 1 : 0);
|
|
73
|
+
|
|
74
|
+
const onChunk = (d) => {
|
|
75
|
+
const ch = (d.choices ?? [])[0];
|
|
76
|
+
if (!ch) {
|
|
77
|
+
send('response.in_progress', { response: shell });
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
const delta = ch.delta ?? {};
|
|
81
|
+
let spoke = false;
|
|
82
|
+
if (typeof delta.content === 'string' && delta.content.length) {
|
|
83
|
+
spoke = true;
|
|
84
|
+
ensureText();
|
|
85
|
+
text += delta.content;
|
|
86
|
+
send('response.output_text.delta', {
|
|
87
|
+
item_id: itemId, output_index: 0, content_index: 0, delta: delta.content,
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
for (const tc of (delta.tool_calls ?? [])) {
|
|
91
|
+
spoke = true;
|
|
92
|
+
const idx = Number.isInteger(tc.index) ? tc.index : tools.length;
|
|
93
|
+
if (!tools[idx]) {
|
|
94
|
+
const id = tc.id || `call_${idx}`;
|
|
95
|
+
const name = tc.function?.name || '';
|
|
96
|
+
const isCustom = customNames.has(name);
|
|
97
|
+
tools[idx] = {
|
|
98
|
+
id, call_id: id, name, arguments: '',
|
|
99
|
+
type: isCustom ? 'custom_tool_call' : 'function_call',
|
|
100
|
+
};
|
|
101
|
+
send('response.output_item.added', {
|
|
102
|
+
output_index: toolIndexBase() + idx,
|
|
103
|
+
item: {
|
|
104
|
+
id, type: tools[idx].type, status: 'in_progress',
|
|
105
|
+
call_id: id, name, arguments: '',
|
|
106
|
+
},
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
const piece = tc.function?.arguments || '';
|
|
110
|
+
if (!piece) continue;
|
|
111
|
+
tools[idx].arguments += piece;
|
|
112
|
+
const isCustom = tools[idx].type === 'custom_tool_call';
|
|
113
|
+
// Custom tools want raw text, but stream pieces are JSON fragments of
|
|
114
|
+
// {input:"..."}. Buffer and emit once at finish. Function tools stream.
|
|
115
|
+
if (!isCustom) {
|
|
116
|
+
send('response.function_call_arguments.delta', {
|
|
117
|
+
item_id: tools[idx].id,
|
|
118
|
+
output_index: toolIndexBase() + idx,
|
|
119
|
+
delta: piece,
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
if (!spoke) send('response.in_progress', { response: shell });
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
const finish = () => {
|
|
127
|
+
if (textStarted) {
|
|
128
|
+
send('response.output_text.done', {
|
|
129
|
+
item_id: itemId, output_index: 0, content_index: 0, text,
|
|
130
|
+
});
|
|
131
|
+
send('response.content_part.done', {
|
|
132
|
+
item_id: itemId, output_index: 0, content_index: 0,
|
|
133
|
+
part: { type: 'output_text', text, annotations: [] },
|
|
134
|
+
});
|
|
135
|
+
send('response.output_item.done', {
|
|
136
|
+
output_index: 0,
|
|
137
|
+
item: {
|
|
138
|
+
id: itemId, type: 'message', role: 'assistant', status: 'completed',
|
|
139
|
+
content: [{ type: 'output_text', text, annotations: [] }],
|
|
140
|
+
},
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
const output = [];
|
|
144
|
+
if (textStarted) {
|
|
145
|
+
output.push({
|
|
146
|
+
id: itemId, type: 'message', role: 'assistant', status: 'completed',
|
|
147
|
+
content: [{ type: 'output_text', text, annotations: [] }],
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
tools.forEach((call, i) => {
|
|
151
|
+
if (!call) return;
|
|
152
|
+
const idx = toolIndexBase() + i;
|
|
153
|
+
const isCustom = call.type === 'custom_tool_call';
|
|
154
|
+
const raw = isCustom ? unwrapCustom(call.arguments) : call.arguments;
|
|
155
|
+
send(isCustom ? 'response.custom_tool_call_input.delta' : 'response.function_call_arguments.delta', {
|
|
156
|
+
item_id: call.id, output_index: idx, delta: isCustom ? raw : '',
|
|
157
|
+
});
|
|
158
|
+
send(isCustom ? 'response.custom_tool_call_input.done' : 'response.function_call_arguments.done', {
|
|
159
|
+
item_id: call.id, output_index: idx,
|
|
160
|
+
...(isCustom ? { input: raw } : { arguments: call.arguments }),
|
|
161
|
+
});
|
|
162
|
+
const item = isCustom
|
|
163
|
+
? { id: call.id, type: 'custom_tool_call', status: 'completed', call_id: call.call_id, name: call.name, input: raw }
|
|
164
|
+
: { id: call.id, type: 'function_call', status: 'completed', call_id: call.call_id, name: call.name, arguments: call.arguments };
|
|
165
|
+
send('response.output_item.done', { output_index: idx, item });
|
|
166
|
+
output.push(item);
|
|
167
|
+
});
|
|
168
|
+
send('response.completed', {
|
|
169
|
+
response: { ...shell, status: 'completed', output, output_text: text },
|
|
170
|
+
});
|
|
171
|
+
res.write('data: [DONE]\n\n');
|
|
172
|
+
res.end();
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
return { onChunk, finish };
|
|
176
|
+
}
|
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];
|
package/lib/think.js
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Model reasoning arrives on several wires:
|
|
3
|
+
* - leaked `<think>…</think>` / `<thinking>…` in the visible reply
|
|
4
|
+
* - `reasoning_content` / `reasoning` / `thinking` / `thought` on the delta
|
|
5
|
+
* - provider objects with a plaintext summary next to encrypted blobs
|
|
6
|
+
*
|
|
7
|
+
* Encrypted reasoning is not a thought we can show — never dump ciphertext
|
|
8
|
+
* into the canvas. Empty plaintext means no thinking chip.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const THINK_BLOCK = /<think(?:ing)?\b[^>]*>[\s\S]*?<\/think(?:ing)?>/gi;
|
|
12
|
+
const THINK_OPEN = /<think(?:ing)?\b[^>]*>[\s\S]*$/i;
|
|
13
|
+
const THINK_CLOSE = /<\/think(?:ing)?>/gi;
|
|
14
|
+
|
|
15
|
+
const REASON_KEYS = [
|
|
16
|
+
'reasoning_content',
|
|
17
|
+
'reasoning',
|
|
18
|
+
'reasoning_details',
|
|
19
|
+
'thinking',
|
|
20
|
+
'thought',
|
|
21
|
+
'thoughts',
|
|
22
|
+
'reasoning_text',
|
|
23
|
+
'summary',
|
|
24
|
+
'text',
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
export function looksEncryptedReasoning(s) {
|
|
28
|
+
const t = String(s || '').trim();
|
|
29
|
+
if (t.length < 48) return false;
|
|
30
|
+
if (/\s/.test(t)) return false;
|
|
31
|
+
return /^[A-Za-z0-9+/_=-]+$/.test(t);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function keyLooksEncrypted(key) {
|
|
35
|
+
return /encrypt/i.test(String(key || ''));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Pull displayable reasoning out of a provider field. Strings that look like
|
|
40
|
+
* ciphertext, and objects typed/keyed as encrypted, become ''.
|
|
41
|
+
*/
|
|
42
|
+
export function reasoningPlaintext(value, depth = 0) {
|
|
43
|
+
if (value == null || depth > 6) return '';
|
|
44
|
+
if (typeof value === 'string') return looksEncryptedReasoning(value) ? '' : value;
|
|
45
|
+
if (typeof value !== 'object') return '';
|
|
46
|
+
if (Array.isArray(value)) {
|
|
47
|
+
return value.map((x) => reasoningPlaintext(x, depth + 1)).filter(Boolean).join('');
|
|
48
|
+
}
|
|
49
|
+
const type = String(value.type || value.kind || '');
|
|
50
|
+
if (/encrypt/i.test(type)) return '';
|
|
51
|
+
const parts = [];
|
|
52
|
+
for (const key of REASON_KEYS) {
|
|
53
|
+
if (!(key in value) || keyLooksEncrypted(key)) continue;
|
|
54
|
+
const inner = reasoningPlaintext(value[key], depth + 1);
|
|
55
|
+
if (inner) parts.push(inner);
|
|
56
|
+
}
|
|
57
|
+
return parts.join('');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** True when a delta/message carried a reasoning field, including encrypted. */
|
|
61
|
+
export function reasoningPresent(delta, message) {
|
|
62
|
+
for (const src of [delta, message]) {
|
|
63
|
+
if (!src || typeof src !== 'object') continue;
|
|
64
|
+
if (src.reasoning_content != null || src.reasoning != null
|
|
65
|
+
|| src.reasoning_details != null || src.thinking != null
|
|
66
|
+
|| src.thought != null || src.thoughts != null
|
|
67
|
+
|| src.reasoning_text != null || src.encrypted_content != null
|
|
68
|
+
|| src.reasoning_encrypted_content != null) return true;
|
|
69
|
+
}
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Reasoning on a chat message or stream delta — never `message.content`. */
|
|
74
|
+
export function messageReasoning(msg, delta) {
|
|
75
|
+
const blobs = [];
|
|
76
|
+
for (const src of [delta, msg]) {
|
|
77
|
+
if (!src || typeof src !== 'object') continue;
|
|
78
|
+
blobs.push(reasoningPlaintext(src.reasoning_content));
|
|
79
|
+
blobs.push(reasoningPlaintext(src.reasoning));
|
|
80
|
+
blobs.push(reasoningPlaintext(src.reasoning_details));
|
|
81
|
+
blobs.push(reasoningPlaintext(src.thinking));
|
|
82
|
+
blobs.push(reasoningPlaintext(src.thought));
|
|
83
|
+
blobs.push(reasoningPlaintext(src.thoughts));
|
|
84
|
+
blobs.push(reasoningPlaintext(src.reasoning_text));
|
|
85
|
+
}
|
|
86
|
+
return blobs.filter((s) => String(s || '').trim()).join('');
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function splitThink(text) {
|
|
90
|
+
const thoughts = [];
|
|
91
|
+
let s = String(text ?? '');
|
|
92
|
+
s = s.replace(THINK_BLOCK, (m) => {
|
|
93
|
+
const a = m.indexOf('>');
|
|
94
|
+
const b = m.toLowerCase().lastIndexOf('</think');
|
|
95
|
+
if (a >= 0 && b > a) thoughts.push(m.slice(a + 1, b));
|
|
96
|
+
return '';
|
|
97
|
+
});
|
|
98
|
+
s = s.replace(THINK_OPEN, (m) => {
|
|
99
|
+
const a = m.indexOf('>');
|
|
100
|
+
thoughts.push(a >= 0 ? m.slice(a + 1) : '');
|
|
101
|
+
return '';
|
|
102
|
+
});
|
|
103
|
+
s = s.replace(THINK_CLOSE, '');
|
|
104
|
+
const visible = s.replace(/^\n+|\n+$/g, '').trim();
|
|
105
|
+
const thinking = thoughts.join('\n').replace(/^\n+|\n+$/g, '').trim();
|
|
106
|
+
return { visible, thinking };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function stripThinkTags(text) {
|
|
110
|
+
return splitThink(text).visible;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function wrapThink(thinking, visible) {
|
|
114
|
+
const t = String(thinking || '').trim();
|
|
115
|
+
const v = String(visible || '');
|
|
116
|
+
if (!t) return v;
|
|
117
|
+
return `<think>${t}</think>\n${v}`;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Persist thinking on a history row; never leave tags in the visible text. */
|
|
121
|
+
export function takeThink(raw, extraThink) {
|
|
122
|
+
const parts = splitThink(raw);
|
|
123
|
+
const extra = String(extraThink || '').trim();
|
|
124
|
+
const thinking = [extra, parts.thinking].filter(Boolean).join('\n').trim();
|
|
125
|
+
return { text: parts.visible, thinking: thinking || undefined };
|
|
126
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "openzoo",
|
|
3
|
-
"version": "0.49.
|
|
3
|
+
"version": "0.49.10",
|
|
4
4
|
"description": "Local x402-paying proxy + MCP server for openzoo.fun — point any OpenAI-compatible harness (Cursor, Claude Code, aider, SDKs) at localhost and it pays per call from a local burner wallet. Solana and Base rails live; Robinhood experimental.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -10,13 +10,14 @@
|
|
|
10
10
|
"files": [
|
|
11
11
|
"bin",
|
|
12
12
|
"lib",
|
|
13
|
-
"README.md"
|
|
13
|
+
"README.md",
|
|
14
|
+
"vendor/modelroute"
|
|
14
15
|
],
|
|
15
16
|
"engines": {
|
|
16
17
|
"node": ">=18"
|
|
17
18
|
},
|
|
18
19
|
"scripts": {
|
|
19
|
-
"test": "node --test test/*.test.js && node scripts/assert-grokui-pin.mjs"
|
|
20
|
+
"test": "node --test test/*.test.js && node scripts/assert-grokui-pin.mjs && node scripts/assert-app-html-script.mjs && node scripts/assert-overlaid-openzoo.mjs"
|
|
20
21
|
},
|
|
21
22
|
"dependencies": {
|
|
22
23
|
"@modelcontextprotocol/sdk": "^1.12.0",
|