openzoo 0.43.5 → 0.43.7
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/grokui.mjs +47 -10
- package/lib/podagent.mjs +59 -28
- package/lib/proxy.js +17 -0
- package/package.json +1 -1
package/lib/grokui.mjs
CHANGED
|
@@ -115,7 +115,13 @@ Via RUN you can also make YOUR OWN paid openzoo calls — POST to
|
|
|
115
115
|
http://localhost:8402/v1/chat/completions (or /v1/hrr/bind) with curl/python/etc. Auth is
|
|
116
116
|
"Authorization: Bearer sk-openzoo" — any string works, x402 pays per call, not the key. Do
|
|
117
117
|
NOT tell the user you "can't fire the paid calls" or need "their client's bearer key" —
|
|
118
|
-
that's wrong, you can make these calls yourself via RUN.
|
|
118
|
+
that's wrong, you can make these calls yourself via RUN. When you do, set max_tokens
|
|
119
|
+
generously (1000+, not 50) — a reasoning model can burn its ENTIRE budget on internal
|
|
120
|
+
reasoning before writing any visible answer, especially against a large bound corpus, and
|
|
121
|
+
comes back with content:null and finish_reason:"length" (confirmed live) if you starve it.
|
|
122
|
+
/v1/hrr/bind also caps around ~8MB per request after JSON-escaping — chunk large corpora
|
|
123
|
+
(e.g. 512KB raw per request) and pass the PREVIOUS chunk's context_id on each next request
|
|
124
|
+
to append to the same bound context, rather than one giant request that silently fails partway.
|
|
119
125
|
For normal questions just answer directly — do not use any of these unless the request
|
|
120
126
|
actually calls for delegation or file work.`;
|
|
121
127
|
|
|
@@ -230,17 +236,39 @@ function newGroupThread(names) {
|
|
|
230
236
|
// turn's brain()/brainStream() call picks up t.contextId once it lands, via
|
|
231
237
|
// the X-HRR-Context header, so retrieval is real and automatic, not a prompt
|
|
232
238
|
// claim about a mechanism that doesn't exist.
|
|
239
|
+
// Chunked, not one shot: a single request over ~8MB (post JSON-escaping)
|
|
240
|
+
// gets rejected, so a large/growing thread's bind would silently fail past
|
|
241
|
+
// whatever point it crossed that line — confirmed live by a bot's own RUN
|
|
242
|
+
// diagnostic ("Failed at chunk 2 — JSON escaping pushed a 3MB chunk over the
|
|
243
|
+
// ~8MB request limit"). 512KB raw per request leaves wide margin. Each
|
|
244
|
+
// chunk after the first carries the PREVIOUS chunk's context_id so the
|
|
245
|
+
// sidecar appends to the same bound context instead of starting fresh.
|
|
246
|
+
const BIND_CHUNK_BYTES = 512 * 1024;
|
|
233
247
|
async function bindThread(t) {
|
|
234
|
-
|
|
235
|
-
|
|
248
|
+
// Only bind what's NEW since the last successful bind, continuing the
|
|
249
|
+
// existing context_id — previously this rebuilt and re-sent the WHOLE
|
|
250
|
+
// history from scratch every turn (discarding t.contextId), so bind cost
|
|
251
|
+
// grew with total conversation length and was re-paid on every message.
|
|
252
|
+
const from = t.boundHistoryCount || 0;
|
|
253
|
+
const delta = t.history.slice(from);
|
|
254
|
+
if (!delta.length) return;
|
|
255
|
+
const corpus = delta.map((h) => (h.who === 'user' ? 'you' : (h.name || t.name)) + ': ' + h.text).join('\n');
|
|
256
|
+
if (!corpus.trim()) { t.boundHistoryCount = t.history.length; return; }
|
|
236
257
|
try {
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
body:
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
258
|
+
let ctx = t.contextId;
|
|
259
|
+
for (let i = 0; i < corpus.length; i += BIND_CHUNK_BYTES) {
|
|
260
|
+
const part = corpus.slice(i, i + BIND_CHUNK_BYTES);
|
|
261
|
+
const body = ctx ? { corpus: part, context_id: ctx } : { corpus: part };
|
|
262
|
+
const r = await fetch(`${PROXY}/hrr/bind`, {
|
|
263
|
+
method: 'POST',
|
|
264
|
+
headers: { 'content-type': 'application/json' },
|
|
265
|
+
body: JSON.stringify(body),
|
|
266
|
+
});
|
|
267
|
+
const j = await r.json().catch(() => ({}));
|
|
268
|
+
if (j?.context_id) ctx = j.context_id;
|
|
269
|
+
else break; // this chunk failed — stop, keep whatever bound so far rather than lose it all
|
|
270
|
+
}
|
|
271
|
+
if (ctx) { t.contextId = ctx; t.boundHistoryCount = t.history.length; saveThreads(); }
|
|
244
272
|
} catch { /* leCore sidecar unreachable — thread still works, just not bound this round */ }
|
|
245
273
|
}
|
|
246
274
|
|
|
@@ -249,6 +277,15 @@ async function bindThread(t) {
|
|
|
249
277
|
// anything runs; 'auto' mode (set via "/mode auto" in chat) runs immediately.
|
|
250
278
|
// Either way this is not sandboxed like WRITE/READ — it can do anything the
|
|
251
279
|
// signed-in user's shell can — so 'ask' is the default, not 'auto'.
|
|
280
|
+
// Some underlying models were tuned with native tool-calling and leak their
|
|
281
|
+
// own control tokens (e.g. DeepSeek's "<||DSML||tool_calls>") as trailing
|
|
282
|
+
// plain text when this harness doesn't wire a `tools` schema. Left in, that
|
|
283
|
+
// text becomes part of the shell command string and breaks /bin/sh's parser
|
|
284
|
+
// ("unexpected token `newline'") — confirmed live. Strip it before exec.
|
|
285
|
+
function sanitizeRunCommand(command) {
|
|
286
|
+
return command.replace(/<\|+[^<>\n]*\|+>/g, '').trim();
|
|
287
|
+
}
|
|
288
|
+
|
|
252
289
|
function execCommand(command, cwd) {
|
|
253
290
|
return new Promise((resolve) => {
|
|
254
291
|
exec(command, { cwd, timeout: 120000, maxBuffer: 10 * 1024 * 1024 }, (err, stdout, stderr) => {
|
package/lib/podagent.mjs
CHANGED
|
@@ -191,53 +191,84 @@ function hasImages(messages) {
|
|
|
191
191
|
// A non-ok response with no usable content used to silently become '', which
|
|
192
192
|
// grokui.mjs then renders as a generic "(no response)" — indistinguishable
|
|
193
193
|
// from a model that genuinely had nothing to say. Callers should know WHY.
|
|
194
|
-
function httpErrorNote(status) {
|
|
195
|
-
if (status === 402)
|
|
194
|
+
async function httpErrorNote(status) {
|
|
195
|
+
if (status === 402) {
|
|
196
|
+
// print the ACTUAL address in the chat, not a link elsewhere to go find it.
|
|
197
|
+
// fetch() does NOT reject on 4xx/5xx, so an older proxy without /wallet
|
|
198
|
+
// returns an error body that parses fine and yields "undefined" fields —
|
|
199
|
+
// check r.ok and the fields themselves before interpolating them.
|
|
200
|
+
try {
|
|
201
|
+
const r = await fetch(`${PROXY}/wallet`);
|
|
202
|
+
const w = r.ok ? await r.json() : null;
|
|
203
|
+
if (w?.funding && w?.evm) {
|
|
204
|
+
// funded === false is the genuinely-empty case; funded === true after
|
|
205
|
+
// the retries above means the rail/quote failed, not the balance
|
|
206
|
+
if (w.funded === false) {
|
|
207
|
+
return `(payment failed — HTTP 402, the wallet is empty. ${w.funding}. EVM (Base/Robinhood): ${w.evm}.)`;
|
|
208
|
+
}
|
|
209
|
+
return `(payment failed — HTTP 402 after ${PAYMENT_RETRIES} retries, though the wallet holds ${w.balances || 'a balance'}. Send it again; if it keeps failing the quoted asset may not be convertible right now. Fund with: ${w.funding})`;
|
|
210
|
+
}
|
|
211
|
+
} catch { /* proxy unreachable — fall through to the generic note */ }
|
|
212
|
+
return `(payment failed — HTTP 402 after ${PAYMENT_RETRIES} retries. Run \`npx openzoo\` to check wallet balances.)`;
|
|
213
|
+
}
|
|
196
214
|
if (status === 429) return '(rate limited — HTTP 429, try again in a moment)';
|
|
197
215
|
if (status >= 500) return `(upstream error — HTTP ${status}, try again)`;
|
|
198
216
|
return status ? `(request failed — HTTP ${status})` : '';
|
|
199
217
|
}
|
|
200
218
|
|
|
219
|
+
// A 402 that reached this layer means the proxy's own x402 retry gave up on
|
|
220
|
+
// this attempt, but the NEXT attempt usually settles (measured: same wallet,
|
|
221
|
+
// same rail, second call pays fine). Surfacing that as a chat message makes
|
|
222
|
+
// the user do the retry by hand — so do it here instead.
|
|
223
|
+
const PAYMENT_RETRIES = 3;
|
|
224
|
+
async function postChat(body, contextId) {
|
|
225
|
+
let r;
|
|
226
|
+
for (let attempt = 0; attempt <= PAYMENT_RETRIES; attempt++) {
|
|
227
|
+
r = await fetch(`${PROXY}/chat/completions`, {
|
|
228
|
+
method: 'POST',
|
|
229
|
+
headers: {
|
|
230
|
+
'content-type': 'application/json', authorization: 'Bearer sk-openzoo',
|
|
231
|
+
// real leCore memory for this thread, bound via POST /v1/hrr/bind — NOT
|
|
232
|
+
// a fabricated mechanism. Retrieval runs automatically once this header
|
|
233
|
+
// is set; nothing more for the model to invent or explain.
|
|
234
|
+
...(contextId ? { 'x-hrr-context': contextId } : {}),
|
|
235
|
+
},
|
|
236
|
+
body: JSON.stringify(body),
|
|
237
|
+
});
|
|
238
|
+
if (r.status !== 402 || attempt === PAYMENT_RETRIES) return r;
|
|
239
|
+
await new Promise((res) => setTimeout(res, 800 * (attempt + 1)));
|
|
240
|
+
}
|
|
241
|
+
return r;
|
|
242
|
+
}
|
|
243
|
+
|
|
201
244
|
/** One openzoo chat turn. Paid per call by the box's own wallet via the local
|
|
202
245
|
* proxy — no key, no account. */
|
|
203
246
|
export async function brain(messages, contextId) {
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
},
|
|
213
|
-
// explicit, not relying on the gateway's "inject when caller said nothing"
|
|
214
|
-
// default — an explicit plugins array is always respected as-is, so this
|
|
215
|
-
// guarantees every bot on every model actually has web search, instead of
|
|
216
|
-
// hoping nothing upstream (local proxy, gateway config) already set one.
|
|
217
|
-
// 900 was cutting real (especially web-search-backed) answers off mid-sentence
|
|
218
|
-
body: JSON.stringify({ model: hasImages(messages) ? VISION_MODEL : MODEL, max_tokens: 4096, messages, plugins: [{ id: 'web' }] }),
|
|
219
|
-
});
|
|
247
|
+
// explicit plugins, not relying on the gateway's "inject when caller said
|
|
248
|
+
// nothing" default — an explicit array is always respected as-is, so every
|
|
249
|
+
// bot on every model actually has web search. max_tokens 900 was cutting
|
|
250
|
+
// real (especially web-search-backed) answers off mid-sentence.
|
|
251
|
+
const r = await postChat(
|
|
252
|
+
{ model: hasImages(messages) ? VISION_MODEL : MODEL, max_tokens: 4096, messages, plugins: [{ id: 'web' }] },
|
|
253
|
+
contextId,
|
|
254
|
+
);
|
|
220
255
|
const j = await r.json().catch(() => ({}));
|
|
221
256
|
const content = j?.choices?.[0]?.message?.content;
|
|
222
|
-
return content || (r.ok ? '' : httpErrorNote(r.status));
|
|
257
|
+
return content || (r.ok ? '' : await httpErrorNote(r.status));
|
|
223
258
|
}
|
|
224
259
|
|
|
225
260
|
/** Same call, but streamed — invokes onDelta(text) as tokens arrive (for a
|
|
226
261
|
* live-typing UI) and resolves with the full accumulated text at the end, so
|
|
227
262
|
* callers that need to parse a directive out of the complete reply still can. */
|
|
228
263
|
export async function brainStream(messages, onDelta, contextId) {
|
|
229
|
-
const r = await
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
...(contextId ? { 'x-hrr-context': contextId } : {}),
|
|
234
|
-
},
|
|
235
|
-
body: JSON.stringify({ model: hasImages(messages) ? VISION_MODEL : MODEL, max_tokens: 4096, messages, plugins: [{ id: 'web' }], stream: true }),
|
|
236
|
-
});
|
|
264
|
+
const r = await postChat(
|
|
265
|
+
{ model: hasImages(messages) ? VISION_MODEL : MODEL, max_tokens: 4096, messages, plugins: [{ id: 'web' }], stream: true },
|
|
266
|
+
contextId,
|
|
267
|
+
);
|
|
237
268
|
if (!r.ok || !r.body) {
|
|
238
269
|
// fall back to the non-streaming path rather than fail outright
|
|
239
270
|
const content = await r.json().then((j) => j?.choices?.[0]?.message?.content).catch(() => undefined);
|
|
240
|
-
const text = content || (r.ok ? '' : httpErrorNote(r.status));
|
|
271
|
+
const text = content || (r.ok ? '' : await httpErrorNote(r.status));
|
|
241
272
|
if (text) onDelta(text);
|
|
242
273
|
return text;
|
|
243
274
|
}
|
package/lib/proxy.js
CHANGED
|
@@ -355,6 +355,23 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
|
|
|
355
355
|
return;
|
|
356
356
|
}
|
|
357
357
|
|
|
358
|
+
// Public addresses only — never the private key. Exists so a caller (the
|
|
359
|
+
// grokui error path, in particular) can print REAL funding instructions
|
|
360
|
+
// inline instead of telling the user to go look somewhere else.
|
|
361
|
+
if (req.method === 'GET' && (req.url || '').split('?')[0] === '/v1/wallet') {
|
|
362
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
363
|
+
res.end(JSON.stringify({
|
|
364
|
+
solana: client.address,
|
|
365
|
+
evm: client.evmAddress,
|
|
366
|
+
funding: fundingLine(client.address),
|
|
367
|
+
// from the same background poll the startup/refresh lines use, so a
|
|
368
|
+
// caller can tell a genuinely empty wallet from a transient 402
|
|
369
|
+
balances: balanceLine(lastSnap || []) || null,
|
|
370
|
+
funded: (lastSnap || []).some((b) => b.ui > 0),
|
|
371
|
+
}));
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
374
|
+
|
|
358
375
|
if ((req.url || '').split('?')[0] === '/mcp') {
|
|
359
376
|
try {
|
|
360
377
|
const { handleMcpRequest } = await import('./mcphttp.js');
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "openzoo",
|
|
3
|
-
"version": "0.43.
|
|
3
|
+
"version": "0.43.7",
|
|
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",
|