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/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, ALIAS_IDS, rewriteChatModel, zooModelIds, CLASSIFY_MAX_TOKENS } from './models.js';
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
- bodyBuf = Buffer.from(JSON.stringify(responsesToChat(inbound, meta)));
1281
+ const translated = responsesToChat(inbound, meta);
1282
+ // Codex's watchdog is ~10s to first SSE byte. Force stream so we can
1283
+ // emit response.created immediately instead of buffering Opus.
1284
+ translated.stream = true;
1285
+ bodyBuf = Buffer.from(JSON.stringify(translated));
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
- // Full zoo catalog � never collapse to a single opus-5 or a claude-*
1512
- // allowlist. Claude Code (ANTHROPIC_BASE_URL + gateway discovery) reads
1513
- // data[].id / display_name; OpenAI clients keep object:"list".
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 && ALIAS_IDS.includes(decodeURIComponent(probe[1]))) {
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
- const cost = money(usage?.cost) ?? money(x?.actualUsd) ?? money(usage?.actualUsd);
258
+ // The gateway now sets `usage.billedUsd` explicitly, and it is exactly the
259
+ // settled figure this function spends the rest of its body reconstructing
260
+ // from tokens and prices. Take it when it is there; the reconstruction below
261
+ // stays for older gateways and for the race path, which has no single
262
+ // billed number of its own.
263
+ const direct = money(usage?.billedUsd);
264
+ if (direct != null) return direct;
265
+ // See pairActualBilled: `usage.cost` is our price now, not upstream's.
266
+ const cost = money(usage?.cost_details?.upstream_inference_cost)
267
+ ?? money(usage?.cost) ?? money(x?.actualUsd) ?? money(usage?.actualUsd);
259
268
  const explicit = explicitSettledBilled(x, usage);
260
269
  if (explicit != null) return explicit;
261
270
 
@@ -295,7 +304,19 @@ function settledSavedUsd(x, cost) {
295
304
  * twin. Null when this call did not report a real cost — do not mix populations.
296
305
  */
297
306
  export function pairActualBilled(x402, usage) {
298
- const fromUsage = money(usage?.cost);
307
+ // `usage.cost` IS NO LONGER THE UPSTREAM FIGURE. The gateway now reports OUR
308
+ // price there, because that is the field every generic OpenAI-compatible
309
+ // client reads and they were all showing what OpenRouter charged us instead
310
+ // of what the caller was charged. The upstream number moved to
311
+ // `cost_details.upstream_inference_cost`, which is where OpenRouter itself
312
+ // publishes it.
313
+ //
314
+ // This is the HUD's DENOMINATOR, so reading the new `usage.cost` here would
315
+ // divide billed by billed and print 1× on every call regardless of markup.
316
+ // Prefer the explicit upstream field; fall back to `usage.cost` only for
317
+ // responses from a gateway old enough to predate the move.
318
+ const fromUpstream = money(usage?.cost_details?.upstream_inference_cost);
319
+ const fromUsage = fromUpstream ?? money(usage?.cost);
299
320
  const fromX = money(x402?.actualUsd) ?? money(x402?.usage?.cost);
300
321
  const upstreamUsd = fromUsage ?? fromX;
301
322
  if (upstreamUsd == null) return null;
@@ -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
+ }
@@ -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
- const minChars = opts.minPrefixChars ?? SPILL_MIN_PREFIX_CHARS;
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.8",
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",