openzoo 0.49.7 → 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/lib/racesettle.js CHANGED
@@ -180,6 +180,133 @@ export function receiptDirectUsd(x) {
180
180
  return billedOk ? billed : 0;
181
181
  }
182
182
 
183
+ /** Zoo's share of (direct − OpenRouter) when the 402 has real savings. */
184
+ export const SAVINGS_SHARE = 0.33;
185
+ /**
186
+ * billed / usage.cost above this, without settled house cogs, is the
187
+ * max_tokens quote reserve — not the charge after completion.
188
+ * MEASURED 2026-08-19: $0.9858 reserved / $0.007962 usage.cost ≈ 124× on a ~1× call.
189
+ */
190
+ export const QUOTE_RESERVE_X = 2;
191
+
192
+ function money(v) {
193
+ return typeof v === 'number' && Number.isFinite(v) && v >= 0 ? v : null;
194
+ }
195
+
196
+ function closeRatio(a, b, rel = 0.25) {
197
+ const den = Math.max(b, 1e-12);
198
+ return Math.abs(a - b) / den <= rel;
199
+ }
200
+
201
+ /**
202
+ * After-completion billed fields the gateway may already put on x402 / usage.
203
+ * Read only if present — do not invent them on the wire.
204
+ */
205
+ function explicitSettledBilled(x, usage) {
206
+ const bags = [x, usage, x?.used, x?.settled, x?.receipt, usage?.used, usage?.settled]
207
+ .filter((o) => o && typeof o === 'object' && !Array.isArray(o));
208
+ const keys = [
209
+ 'billedActual', 'billedActualUsd', 'settledUsd', 'settledBilledUsd',
210
+ 'chargedUsd', 'usedUsd', 'actualBilledUsd',
211
+ ];
212
+ for (const bag of bags) {
213
+ for (const k of keys) {
214
+ const n = money(bag[k]);
215
+ if (n != null) return n;
216
+ }
217
+ }
218
+ return null;
219
+ }
220
+
221
+ /** tokens actually used × unit prices, only when both sides are already on the object. */
222
+ function billedFromUsedTokens(x, usage) {
223
+ const prompt = money(usage?.prompt_tokens ?? usage?.promptTokens ?? x?.prompt_tokens);
224
+ const completion = money(usage?.completion_tokens ?? usage?.completionTokens ?? x?.completion_tokens);
225
+ const inPrice = money(x?.promptPriceUsd ?? x?.inputPriceUsd ?? x?.priceInUsd);
226
+ const outPrice = money(x?.completionPriceUsd ?? x?.outputPriceUsd ?? x?.priceOutUsd);
227
+ if (prompt != null && completion != null && inPrice != null && outPrice != null) {
228
+ return prompt * inPrice + completion * outPrice;
229
+ }
230
+ return null;
231
+ }
232
+
233
+ /**
234
+ * True when `billed` is the quote-time max_tokens ceiling, not the settled charge.
235
+ * A large billed/cost is honest when cogs already matches usage.cost (33% of
236
+ * real savings). The 124× lie is billed >> cost with at-cost / reserved cogs.
237
+ */
238
+ export function isQuoteReserveBilled(billed, cost, x = {}) {
239
+ if (money(billed) == null || money(cost) == null) return false;
240
+ if (cost === 0) return billed > 0;
241
+ if (billed <= cost * QUOTE_RESERVE_X) return false;
242
+ const cogs = money(x?.cogsUsd);
243
+ if (cogs != null && closeRatio(cogs, cost)) return false;
244
+ const saved = money(x?.savedUsd);
245
+ if (saved == null || saved <= cost * 0.5) return true;
246
+ if (cogs != null && cogs > cost * QUOTE_RESERVE_X) return true;
247
+ return true;
248
+ }
249
+
250
+ /**
251
+ * Post-completion billed USD to pair with usage.cost / x.actualUsd.
252
+ * `x.billedUsd` is often the quote reserve (max_tokens × catalog), which made
253
+ * HUD markupX read 124× on a ~1× call. Prefer a settled field; otherwise
254
+ * reconstruct from tokens used × price or from usage.cost (+ 33% of settled
255
+ * savings). Never return the reserve when we learned the real upstream cost.
256
+ */
257
+ export function receiptSettledBilled(x, usage) {
258
+ const cost = money(usage?.cost) ?? money(x?.actualUsd) ?? money(usage?.actualUsd);
259
+ const explicit = explicitSettledBilled(x, usage);
260
+ if (explicit != null) return explicit;
261
+
262
+ const fromTokens = billedFromUsedTokens(x, usage);
263
+ if (fromTokens != null) {
264
+ const billed = money(x?.billedUsd);
265
+ if (billed == null || isQuoteReserveBilled(billed, fromTokens, x)
266
+ || (cost != null && isQuoteReserveBilled(billed, cost, x))) {
267
+ return fromTokens;
268
+ }
269
+ return billed;
270
+ }
271
+
272
+ const billed = money(x?.billedUsd);
273
+ if (billed != null && (cost == null || !isQuoteReserveBilled(billed, cost, x))) {
274
+ return billed;
275
+ }
276
+ if (cost != null) {
277
+ const saved = settledSavedUsd(x, cost);
278
+ return cost + SAVINGS_SHARE * saved;
279
+ }
280
+ return billed ?? 0;
281
+ }
282
+
283
+ function settledSavedUsd(x, cost) {
284
+ const saved = money(x?.savedUsd);
285
+ if (saved == null || saved <= 0) return 0;
286
+ const cogs = money(x?.cogsUsd);
287
+ // Quote-time savedUsd rides the same max_tokens reserve. Only keep it when
288
+ // house cost already matches the metered upstream (settled cogs).
289
+ if (cogs != null && closeRatio(cogs, cost)) return saved;
290
+ return 0;
291
+ }
292
+
293
+ /**
294
+ * Pair the HUD denominator (real upstream) with the post-completion billed
295
+ * twin. Null when this call did not report a real cost — do not mix populations.
296
+ */
297
+ export function pairActualBilled(x402, usage) {
298
+ const fromUsage = money(usage?.cost);
299
+ const fromX = money(x402?.actualUsd) ?? money(x402?.usage?.cost);
300
+ const upstreamUsd = fromUsage ?? fromX;
301
+ if (upstreamUsd == null) return null;
302
+ const bag = x402 && typeof x402 === 'object' ? x402 : {};
303
+ const usageBag = usage && typeof usage === 'object' ? usage : {};
304
+ return {
305
+ upstreamUsd,
306
+ billedUsd: receiptSettledBilled(bag, { ...usageBag, cost: upstreamUsd }),
307
+ };
308
+ }
309
+
183
310
  /**
184
311
  * Session meter. spent/direct are the receipt totals as billed — never a
185
312
  * first-call rewrite, never a race_unused user refund.
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
+ }
@@ -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,7 +1,7 @@
1
1
  {
2
2
  "name": "openzoo",
3
- "version": "0.49.7",
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.",
3
+ "version": "0.49.9",
4
+ "description": "Local x402-paying proxy + MCP server for openzoo.fun \u2014 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",
7
7
  "bin": {
@@ -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",