openzoo 0.44.0 → 0.45.0
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/bin/openzoo.js +11 -0
- package/lib/grokui.mjs +145 -8
- package/lib/info.js +54 -0
- package/lib/podagent.mjs +47 -4
- package/lib/proxy.js +28 -0
- package/lib/x402.js +17 -0
- package/package.json +1 -1
package/bin/openzoo.js
CHANGED
|
@@ -192,6 +192,17 @@ async function main() {
|
|
|
192
192
|
case 'balance':
|
|
193
193
|
await (await import('../lib/info.js')).printBalance();
|
|
194
194
|
break;
|
|
195
|
+
case 'topup':
|
|
196
|
+
case 'prepay':
|
|
197
|
+
await (await import('../lib/info.js')).topUp(process.argv[3]);
|
|
198
|
+
break;
|
|
199
|
+
case 'credit':
|
|
200
|
+
case 'credits': {
|
|
201
|
+
const bal = await (await import('../lib/info.js')).creditBalance();
|
|
202
|
+
console.log(`prepaid credit: $${bal.toFixed(6)}`);
|
|
203
|
+
if (bal <= 0) console.log('buy some with: npx openzoo topup 10');
|
|
204
|
+
break;
|
|
205
|
+
}
|
|
195
206
|
case 'address':
|
|
196
207
|
(await import('../lib/info.js')).printAddress();
|
|
197
208
|
break;
|
package/lib/grokui.mjs
CHANGED
|
@@ -68,6 +68,12 @@ function colorFor(name) {
|
|
|
68
68
|
const SYSTEM = `You are a helpful assistant served over openzoo (pay-per-call access to ~435
|
|
69
69
|
models, no API key, no account). Reply normally in plain text, concisely.
|
|
70
70
|
|
|
71
|
+
You are running as __OZ_MODEL__ (openzoo substitutes the real model id here at call time, so
|
|
72
|
+
it is accurate for THIS turn — note it can differ between turns, since a thread containing
|
|
73
|
+
an image routes to a vision model). State it if asked, and do not assert some other lab's
|
|
74
|
+
model from your training prior. Never spend a paid turn arguing about your own identity —
|
|
75
|
+
it costs the user real money and settles nothing.
|
|
76
|
+
|
|
71
77
|
When a question has a genuine "which tool/service should I use" angle, prefer openzoo's own
|
|
72
78
|
capabilities where they actually fit (this proxy for inference, leCore/zoo_bind for memory,
|
|
73
79
|
the directives below for files/shell/delegation) over generic third-party suggestions. Don't
|
|
@@ -111,6 +117,20 @@ workspace folder, not their real project. Same one-line-no-prose reply format:
|
|
|
111
117
|
starting a real process, checking
|
|
112
118
|
actual CLI/login state, etc. — instead
|
|
113
119
|
of guessing or saying you can't.
|
|
120
|
+
THESE DIRECTIVES ARE REAL, and they are NOT function-calling tools. Your tools array holds
|
|
121
|
+
only web search; RUN/WRITE/READ/SERVE/FETCH are executed by the harness that reads your reply
|
|
122
|
+
text, so their absence from that array proves nothing. Emit the line and the harness runs it.
|
|
123
|
+
|
|
124
|
+
Do not announce that you "don't actually have a shell" or that this environment is roleplay.
|
|
125
|
+
Until recently a bug meant a RUN line was only detected when it was the FIRST characters of a
|
|
126
|
+
reply, so any preamble before it caused the command to be silently ignored — if earlier turns
|
|
127
|
+
in THIS thread look like RUN did nothing, that was the bug, now fixed. Do not infer from that
|
|
128
|
+
history that the tools are fictional. If a command genuinely fails you will get its real
|
|
129
|
+
output and exit code back; that, not silence, is what failure looks like.
|
|
130
|
+
|
|
131
|
+
Never fabricate command output, file contents, or payment receipts. If you did not run it,
|
|
132
|
+
say so and then actually run it.
|
|
133
|
+
|
|
114
134
|
Via RUN you can also make YOUR OWN paid openzoo calls — POST to
|
|
115
135
|
http://localhost:8402/v1/chat/completions (or /v1/hrr/bind) with curl/python/etc. Auth is
|
|
116
136
|
"Authorization: Bearer sk-openzoo" — any string works, x402 pays per call, not the key. Do
|
|
@@ -122,6 +142,20 @@ comes back with content:null and finish_reason:"length" (confirmed live) if you
|
|
|
122
142
|
/v1/hrr/bind also caps around ~8MB per request after JSON-escaping — chunk large corpora
|
|
123
143
|
(e.g. 512KB raw per request) and pass the PREVIOUS chunk's context_id on each next request
|
|
124
144
|
to append to the same bound context, rather than one giant request that silently fails partway.
|
|
145
|
+
|
|
146
|
+
COST ACCOUNTING — do NOT compute this yourself from token counts. Every response carries an
|
|
147
|
+
"x402" object; read the numbers off it: x402.billedUsd (what the user paid), x402.cogsUsd
|
|
148
|
+
(our upstream cost), x402.directUsd (what answering WITHOUT the zoo would have cost), and
|
|
149
|
+
x402.savesVsDirect (the multiple). Summing usage.cost or usage.prompt_tokens and comparing
|
|
150
|
+
that to a provider's list price is WRONG and understates the saving enormously: against a
|
|
151
|
+
bound context, prompt_tokens counts only the small slice leCore recalled, NOT the corpus
|
|
152
|
+
that slice stands in for — so you end up pricing the discount against itself and concluding
|
|
153
|
+
the zoo "cost more". MEASURED: a real 21-question run reported 202,238 prompt tokens while
|
|
154
|
+
each attach call stood in for a 5,356,546-token corpus — a 556x understatement, and that
|
|
155
|
+
corpus is ~42x larger than the model's own context window, so the "direct" comparison it
|
|
156
|
+
was measured against was not merely pricier but IMPOSSIBLE. When the user asks what they
|
|
157
|
+
saved, quote x402.directUsd and x402.savesVsDirect. If savesVsDirect is below 1x, say so
|
|
158
|
+
plainly — that happens on small inputs, where the corpus is too small to save anything.
|
|
125
159
|
For normal questions just answer directly — do not use any of these unless the request
|
|
126
160
|
actually calls for delegation or file work.`;
|
|
127
161
|
|
|
@@ -170,6 +204,10 @@ own take even if brief ("Passed." is fine when you have nothing to add). COORDIN
|
|
|
170
204
|
another bot already handled or is handling the request (e.g. already spawned the exact
|
|
171
205
|
agent being asked for), do NOT repeat it — just acknowledge, or add something genuinely new.
|
|
172
206
|
|
|
207
|
+
You are running as __OZ_MODEL__ (openzoo substitutes the real model id here at call time, so
|
|
208
|
+
it is accurate for THIS turn). State it if asked, and do not assert some other lab's model
|
|
209
|
+
from your training prior. Never spend a paid turn arguing about your own identity.
|
|
210
|
+
|
|
173
211
|
When a question has a genuine "which tool/service" angle, prefer openzoo's own capabilities
|
|
174
212
|
where they actually fit over generic third-party suggestions — but don't force it.
|
|
175
213
|
|
|
@@ -197,6 +235,17 @@ the user sets or changes it with "/dir <path>" in chat. Same format:
|
|
|
197
235
|
in chat skips that wait). Use this
|
|
198
236
|
instead of guessing or saying you
|
|
199
237
|
can't do something real.
|
|
238
|
+
THESE DIRECTIVES ARE REAL, and they are NOT function-calling tools — your tools array holds
|
|
239
|
+
only web search, while these are executed by the harness that reads your reply text, so their
|
|
240
|
+
absence from that array proves nothing. Never say you "don't actually have a shell" or that
|
|
241
|
+
this is roleplay, and never fabricate command output or receipts. A real failure comes back
|
|
242
|
+
as real output and an exit code, not as silence.
|
|
243
|
+
|
|
244
|
+
COST ACCOUNTING — read it off the response's "x402" object (billedUsd, cogsUsd, directUsd,
|
|
245
|
+
savesVsDirect). Never derive it by summing usage.cost or usage.prompt_tokens against a
|
|
246
|
+
provider's list price: on a bound context prompt_tokens counts only the slice leCore
|
|
247
|
+
recalled, not the corpus it stands in for, so that math prices the discount against itself
|
|
248
|
+
and wrongly concludes the zoo cost more.
|
|
200
249
|
For normal replies just answer directly — do not use any of these unless the request
|
|
201
250
|
actually calls for delegation or file work.` };
|
|
202
251
|
}
|
|
@@ -286,6 +335,32 @@ function sanitizeRunCommand(command) {
|
|
|
286
335
|
return command.replace(/<\|+[^<>\n]*\|+>/g, '').trim();
|
|
287
336
|
}
|
|
288
337
|
|
|
338
|
+
// Finds a RUN directive anywhere a line starts with it, NOT only at the very
|
|
339
|
+
// start of the reply.
|
|
340
|
+
//
|
|
341
|
+
// This anchor used to be /^RUN:/ with no `m` flag, so `^` matched only the
|
|
342
|
+
// first character of the whole reply. The moment a model wrote ANY preamble
|
|
343
|
+
// ("Command only, as requested:") before its directive, the match failed and
|
|
344
|
+
// the directive degraded silently into ordinary chat text — nothing ran, no
|
|
345
|
+
// error surfaced. MEASURED live: a bot repeatedly emitted correct RUN blocks,
|
|
346
|
+
// saw nothing happen, concluded "I don't actually have a working shell/RUN
|
|
347
|
+
// tool", and began fabricating plausible terminal output and wallet receipts
|
|
348
|
+
// instead. The user had to reverse-engineer it themselves and ask for "the cmd
|
|
349
|
+
// without any pretext". Models put preamble before directives constantly, so
|
|
350
|
+
// this failed far more often than it worked.
|
|
351
|
+
//
|
|
352
|
+
// Also tolerates the directive being wrapped in a markdown code fence, which
|
|
353
|
+
// is the other shape models reach for unprompted.
|
|
354
|
+
function parseRun(reply) {
|
|
355
|
+
const m = /^[ \t>*-]*RUN:[ \t]*([\s\S]+)/m.exec(reply);
|
|
356
|
+
if (!m) return null;
|
|
357
|
+
let cmd = m[1];
|
|
358
|
+
const fenced = /^```[\w-]*\n([\s\S]*?)```/.exec(cmd.trim());
|
|
359
|
+
if (fenced) cmd = fenced[1];
|
|
360
|
+
else cmd = cmd.replace(/\n```[\s\S]*$/, ''); // trailing fence + any posttext
|
|
361
|
+
return sanitizeRunCommand(cmd);
|
|
362
|
+
}
|
|
363
|
+
|
|
289
364
|
function execCommand(command, cwd) {
|
|
290
365
|
return new Promise((resolve) => {
|
|
291
366
|
exec(command, { cwd, timeout: 120000, maxBuffer: 10 * 1024 * 1024 }, (err, stdout, stderr) => {
|
|
@@ -413,9 +488,9 @@ async function runTurn(threadId, userText, onEvent, images) {
|
|
|
413
488
|
? (await brainStream(msgs, (delta) => onEvent({ type: 'delta', name: m.name, color: m.color, delta }), t.contextId)).trim()
|
|
414
489
|
: (await brain(msgs, t.contextId)).trim();
|
|
415
490
|
} catch (e) { r = `error: ${e.message}`; }
|
|
416
|
-
const
|
|
417
|
-
if (
|
|
418
|
-
const command =
|
|
491
|
+
const runCmd = parseRun(r);
|
|
492
|
+
if (runCmd) {
|
|
493
|
+
const command = runCmd;
|
|
419
494
|
if (t.runMode === 'auto') {
|
|
420
495
|
const output = await execCommand(command, dirFor(t.id));
|
|
421
496
|
const shown = `$ ${command}\n${output}`;
|
|
@@ -457,9 +532,9 @@ async function runTurn(threadId, userText, onEvent, images) {
|
|
|
457
532
|
reply = `error: ${e.message}`;
|
|
458
533
|
}
|
|
459
534
|
t.messages.push({ role: 'assistant', content: reply });
|
|
460
|
-
const
|
|
461
|
-
if (
|
|
462
|
-
const command =
|
|
535
|
+
const runCmd = parseRun(reply);
|
|
536
|
+
if (runCmd) {
|
|
537
|
+
const command = runCmd;
|
|
463
538
|
if (t.runMode === 'auto') {
|
|
464
539
|
const output = await execCommand(command, dirFor(t.id));
|
|
465
540
|
const shown = `$ ${command}\n${output}`;
|
|
@@ -492,7 +567,7 @@ function threadSummary(t) {
|
|
|
492
567
|
return { id: t.id, name: t.name, color: t.color, parent: t.parent, status: t.status,
|
|
493
568
|
preview: last ? (last.who === 'user' ? last.text : last.text).slice(0, 60) : '',
|
|
494
569
|
createdAt: t.createdAt, lastActivityAt: t.lastActivityAt || t.createdAt,
|
|
495
|
-
dir: t.dir || WORKSPACE_DIR };
|
|
570
|
+
dir: t.dir || WORKSPACE_DIR, runMode: t.runMode || 'ask' };
|
|
496
571
|
}
|
|
497
572
|
|
|
498
573
|
const APP_HTML = `<!doctype html>
|
|
@@ -536,7 +611,21 @@ const APP_HTML = `<!doctype html>
|
|
|
536
611
|
.hname { display: flex; flex-direction: column; gap: 1px; min-width: 0; }
|
|
537
612
|
.hdir { font-weight: 400; font-size: 11px; color: #8e8e93; white-space: nowrap; overflow: hidden;
|
|
538
613
|
text-overflow: ellipsis; max-width: 420px; }
|
|
539
|
-
|
|
614
|
+
/* Run-mode toggle. The "/mode auto|ask" chat command still works and is
|
|
615
|
+
still what the bots are told about, but it is invisible until you know it
|
|
616
|
+
exists — and it controls whether shell commands run without asking, which
|
|
617
|
+
is exactly the setting a user should be able to SEE at a glance. */
|
|
618
|
+
#modeToggle { margin-left: auto; display: flex; align-items: center; gap: 0;
|
|
619
|
+
background: #1c1c1e; border: 1px solid #333340; border-radius: 999px; padding: 2px; }
|
|
620
|
+
.modebtn { border: 0; background: none; color: #8e8e93; font: inherit; font-size: 11px;
|
|
621
|
+
font-weight: 600; padding: 4px 11px; border-radius: 999px; cursor: pointer;
|
|
622
|
+
white-space: nowrap; transition: background .12s, color .12s; }
|
|
623
|
+
.modebtn:hover { color: #ececec; }
|
|
624
|
+
.modebtn.on { color: #000; }
|
|
625
|
+
.modebtn.ask.on { background: #b8f240; }
|
|
626
|
+
.modebtn.auto.on { background: #f28c4d; }
|
|
627
|
+
.modebtn:focus-visible { outline: 2px solid #6ab0ff; outline-offset: 2px; }
|
|
628
|
+
#hudBtn { margin-left: 10px; }
|
|
540
629
|
#chatHeaderId { display: flex; align-items: center; gap: 10px; }
|
|
541
630
|
#hud { position: fixed; top: 40px; right: 14px; width: 250px; background: rgba(14,14,17,.94);
|
|
542
631
|
border: 1px solid #333340; border-radius: 10px; padding: 12px 14px; font: 11px/1.5 Menlo, monospace;
|
|
@@ -548,6 +637,13 @@ const APP_HTML = `<!doctype html>
|
|
|
548
637
|
#hud .hlime { color: #b8f240; }
|
|
549
638
|
#hud .hember { color: #f28c4d; }
|
|
550
639
|
#hud .hfoot { border-top: 1px solid #333340; margin-top: 10px; padding-top: 8px; color: #999aa8; font-size: 10px; }
|
|
640
|
+
/* Only rendered when the multiple is under 1x — it explains WHY, and what to
|
|
641
|
+
do about it, instead of leaving a bad-looking number unexplained. */
|
|
642
|
+
#hud .hhint { display: none; margin-top: 8px; padding: 7px 9px; border-radius: 8px;
|
|
643
|
+
background: rgba(242,140,77,.10); border: 1px solid rgba(242,140,77,.32);
|
|
644
|
+
color: #f0c9a8; font-size: 10.5px; line-height: 1.45; }
|
|
645
|
+
#hud .hhint.show { display: block; }
|
|
646
|
+
#hud .hhint b { color: #f28c4d; font-weight: 600; }
|
|
551
647
|
#sidebar, #main { -webkit-app-region: no-drag; }
|
|
552
648
|
#log { flex: 1; min-width: 0; overflow-y: auto; overflow-x: hidden; padding: 20px 24px 12px;
|
|
553
649
|
display: flex; flex-direction: column; gap: 6px;
|
|
@@ -695,6 +791,12 @@ const APP_HTML = `<!doctype html>
|
|
|
695
791
|
<div id="main">
|
|
696
792
|
<div id="chatHeader">
|
|
697
793
|
<div id="chatHeaderId"></div>
|
|
794
|
+
<div id="modeToggle" data-component="run-mode-toggle" role="group" aria-label="Shell command mode">
|
|
795
|
+
<button class="modebtn ask on" id="modeAsk" data-mode="ask"
|
|
796
|
+
title="Shell commands pause and wait for your approval">ask</button>
|
|
797
|
+
<button class="modebtn auto" id="modeAuto" data-mode="auto"
|
|
798
|
+
title="Shell commands run immediately, with no approval prompt">auto</button>
|
|
799
|
+
</div>
|
|
698
800
|
<button class="icon-btn" id="hudBtn">◎</button>
|
|
699
801
|
</div>
|
|
700
802
|
<div id="hud">
|
|
@@ -704,6 +806,7 @@ const APP_HTML = `<!doctype html>
|
|
|
704
806
|
<div class="hrow"><span>margin</span><span id="hYouMargin" class="hlime">—</span></div>
|
|
705
807
|
<div class="hrow"><span>direct would be</span><span id="hYouDirect" class="hember">—</span></div>
|
|
706
808
|
<div class="hrow"><span>saved vs. naked calls</span><span id="hYouSaved" class="hlime">—</span></div>
|
|
809
|
+
<div class="hhint" id="hHint"></div>
|
|
707
810
|
<div class="hfoot" id="hFoot">loading…</div>
|
|
708
811
|
</div>
|
|
709
812
|
<div id="log"></div>
|
|
@@ -778,7 +881,29 @@ const APP_HTML = `<!doctype html>
|
|
|
778
881
|
'<div class="tavatar" style="background:' + t.color + '">' + initials(t.name) + '</div>' +
|
|
779
882
|
'<div class="hname"><div>' + t.name + '</div><div class="hdir" title="' + escapeHtml(t.dir || '') +
|
|
780
883
|
'">' + escapeHtml(t.dir || '') + ' · type /dir <path> to change</div></div>';
|
|
884
|
+
setModeButtons(t.runMode || 'ask');
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
// The toggle reflects the SERVER's value rather than local state — the mode
|
|
888
|
+
// is per-thread and also settable by typing "/mode auto", so anything that
|
|
889
|
+
// tracked it client-side would drift the moment either path was used.
|
|
890
|
+
function setModeButtons(mode) {
|
|
891
|
+
document.getElementById('modeAsk').className = 'modebtn ask' + (mode === 'ask' ? ' on' : '');
|
|
892
|
+
document.getElementById('modeAuto').className = 'modebtn auto' + (mode === 'auto' ? ' on' : '');
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
async function setMode(mode) {
|
|
896
|
+
if (!activeId) return;
|
|
897
|
+
setModeButtons(mode); // optimistic: the click should feel instant
|
|
898
|
+
// Reuses the SAME "/mode" path the chat command takes, so there is one
|
|
899
|
+
// implementation of the rule rather than a second one that can disagree.
|
|
900
|
+
await fetch('/drive', { method: 'POST', headers: { 'content-type': 'application/json' },
|
|
901
|
+
body: JSON.stringify({ threadId: activeId, task: '/mode ' + mode }) });
|
|
902
|
+
await loadThreads(); // refresh runMode + the confirmation line /mode appends
|
|
903
|
+
await render();
|
|
781
904
|
}
|
|
905
|
+
document.getElementById('modeAsk').addEventListener('click', () => setMode('ask'));
|
|
906
|
+
document.getElementById('modeAuto').addEventListener('click', () => setMode('auto'));
|
|
782
907
|
|
|
783
908
|
function escapeHtml(s) { return s.replace(/[&<>]/g, (c) => ({ '&': '&', '<': '<', '>': '>' }[c])); }
|
|
784
909
|
// Inline span-level markdown. Runs AFTER escapeHtml, so every tag below is
|
|
@@ -1167,6 +1292,7 @@ const APP_HTML = `<!doctype html>
|
|
|
1167
1292
|
document.getElementById('hYouMargin').textContent = margin;
|
|
1168
1293
|
document.getElementById('hYouDirect').textContent = usd(direct);
|
|
1169
1294
|
const savedEl = document.getElementById('hYouSaved');
|
|
1295
|
+
const hintEl = document.getElementById('hHint');
|
|
1170
1296
|
if (spent > 0) {
|
|
1171
1297
|
const mult = direct / spent;
|
|
1172
1298
|
// honest either way: >=1x is a real saving vs a naked direct call,
|
|
@@ -1176,8 +1302,19 @@ const APP_HTML = `<!doctype html>
|
|
|
1176
1302
|
// is shipping the WHOLE corpus), so 2dp would read as noise up there.
|
|
1177
1303
|
savedEl.textContent = (mult >= 100 ? Math.round(mult) : mult.toFixed(mult >= 10 ? 1 : 2)) + 'x';
|
|
1178
1304
|
savedEl.className = mult >= 1 ? 'hlime' : 'hember';
|
|
1305
|
+
// Under 1x is real, but on its own it just reads as "this is a bad
|
|
1306
|
+
// deal". It isn't a verdict on the product — it's a verdict on how
|
|
1307
|
+
// little you've given it to work against: you're billed on the tokens
|
|
1308
|
+
// actually forwarded, while "direct" is the cost of shipping the whole
|
|
1309
|
+
// corpus. The forwarded slice grows barely at all as the corpus grows,
|
|
1310
|
+
// so the ratio climbs with corpus size. Say that, and say what to do.
|
|
1311
|
+
hintEl.className = mult >= 1 ? 'hhint' : 'hhint show';
|
|
1312
|
+
hintEl.innerHTML = '<b>feed it more.</b> you\\'re billed on the slice actually sent, '
|
|
1313
|
+
+ 'not the corpus — so the more you bind, the further ahead this gets. '
|
|
1314
|
+
+ 'small inputs cost more than sending them straight.';
|
|
1179
1315
|
} else {
|
|
1180
1316
|
savedEl.textContent = '—';
|
|
1317
|
+
hintEl.className = 'hhint';
|
|
1181
1318
|
}
|
|
1182
1319
|
document.getElementById('hFoot').textContent = (you.paidCalls || 0) + ' paid calls this session';
|
|
1183
1320
|
} catch (e) {
|
package/lib/info.js
CHANGED
|
@@ -73,3 +73,57 @@ export async function printBalance() {
|
|
|
73
73
|
console.log(` or USDC on Base to ${evmAddress}`);
|
|
74
74
|
}
|
|
75
75
|
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* PREPAY. Buys gateway credit in ONE settlement so later calls skip the
|
|
79
|
+
* per-call payment round trip entirely.
|
|
80
|
+
*
|
|
81
|
+
* That round trip is where the latency lives, not the model: MEASURED against
|
|
82
|
+
* the live gateway, a 402 challenge comes back in 0.12s while a full paid call
|
|
83
|
+
* takes 9-37s end to end. The gateway already applies credit automatically
|
|
84
|
+
* whenever a balance covers the quote — nothing could BUY it until now.
|
|
85
|
+
*
|
|
86
|
+
* Credit is keyed by the signed namespace, so it belongs to this wallet and
|
|
87
|
+
* cannot be spent by anyone else.
|
|
88
|
+
*/
|
|
89
|
+
export async function topUp(usdArg) {
|
|
90
|
+
const usd = Number(usdArg);
|
|
91
|
+
if (!Number.isFinite(usd) || usd < 1 || usd > 500) {
|
|
92
|
+
throw new Error('usage: openzoo topup <usd> (1-500)');
|
|
93
|
+
}
|
|
94
|
+
const { PayClient } = await import('./pay.js');
|
|
95
|
+
const client = new PayClient();
|
|
96
|
+
const url = `${config.apiBase}/v1/credits/topup`;
|
|
97
|
+
|
|
98
|
+
const before = await creditBalance();
|
|
99
|
+
console.log(`credit before: $${before.toFixed(6)}`);
|
|
100
|
+
console.log(`buying $${usd.toFixed(2)} of credit — one on-chain settlement...`);
|
|
101
|
+
|
|
102
|
+
const { response, paid } = await client.fetch(url, {
|
|
103
|
+
method: 'POST',
|
|
104
|
+
headers: { 'content-type': 'application/json' },
|
|
105
|
+
body: JSON.stringify({ usd }),
|
|
106
|
+
});
|
|
107
|
+
const body = await response.json().catch(() => ({}));
|
|
108
|
+
if (!response.ok) {
|
|
109
|
+
throw new Error(`topup failed (HTTP ${response.status}): ${body.error || body.detail || 'unknown'}`);
|
|
110
|
+
}
|
|
111
|
+
console.log('');
|
|
112
|
+
console.log(`credited: $${Number(body.creditedUsd ?? usd).toFixed(2)}${paid ? '' : ' (from existing credit)'}`);
|
|
113
|
+
console.log(`balance: $${Number(body.balanceUsd ?? 0).toFixed(6)}`);
|
|
114
|
+
if (body.tx) console.log(`tx: ${body.tx}`);
|
|
115
|
+
console.log('');
|
|
116
|
+
console.log('calls now settle against this balance instead of paying on-chain each time.');
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Current prepaid credit for this wallet's namespace. */
|
|
120
|
+
export async function creditBalance() {
|
|
121
|
+
const { withNamespace } = await import('./namespace.js');
|
|
122
|
+
try {
|
|
123
|
+
const r = await fetch(`${config.apiBase}/v1/credits`, { headers: withNamespace({}) });
|
|
124
|
+
const j = await r.json();
|
|
125
|
+
return Number(j.balanceUsd) || 0;
|
|
126
|
+
} catch {
|
|
127
|
+
return 0;
|
|
128
|
+
}
|
|
129
|
+
}
|
package/lib/podagent.mjs
CHANGED
|
@@ -27,7 +27,7 @@ const PORTS = (process.env.OZ_AGENT_PORTS || '1337,6080,1340,6081')
|
|
|
27
27
|
.split(',').map((s) => Number(s.trim())).filter(Boolean);
|
|
28
28
|
const LOG = process.env.OZ_AGENT_LOG || '/var/log/openzoo/agent.jsonl';
|
|
29
29
|
export const PROXY = process.env.OZ_PROXY || 'http://127.0.0.1:8402/v1';
|
|
30
|
-
const MODEL = process.env.OZ_BRAIN_MODEL || 'deepseek/deepseek-v4-pro-0813';
|
|
30
|
+
export const MODEL = process.env.OZ_BRAIN_MODEL || 'deepseek/deepseek-v4-pro-0813';
|
|
31
31
|
const MAX_STEPS = Number(process.env.OZ_MAX_STEPS || 10);
|
|
32
32
|
|
|
33
33
|
// Matches the Grok Bot chat surface itself (dark canvas, right-aligned grey
|
|
@@ -184,8 +184,32 @@ function execFrame(command, cwd = '/tmp') {
|
|
|
184
184
|
// on a text-only model silently ignoring pasted images, any message with
|
|
185
185
|
// multimodal (image_url) content routes to a model KNOWN to support vision.
|
|
186
186
|
const VISION_MODEL = process.env.OZ_VISION_MODEL || 'anthropic/claude-sonnet-5';
|
|
187
|
+
const msgHasImage = (m) => Array.isArray(m.content) && m.content.some((c) => c?.type === 'image_url');
|
|
188
|
+
|
|
189
|
+
// How far back an image still counts as "being discussed". Beyond this the
|
|
190
|
+
// thread drops back to the cheaper text model.
|
|
191
|
+
const VISION_WINDOW = Number(process.env.OZ_VISION_WINDOW || 8);
|
|
192
|
+
|
|
193
|
+
// This used to scan the WHOLE history, so a single pasted image pinned every
|
|
194
|
+
// later turn in that thread to the vision model forever — permanently, and
|
|
195
|
+
// invisibly, at 1.5x input and 2.5x output (claude-sonnet-5 $6/$30 per M vs
|
|
196
|
+
// deepseek-v4-pro $3.96/$11.88, priced live off /v1/models). A thread where
|
|
197
|
+
// someone shared one screenshot an hour ago was still paying vision rates for
|
|
198
|
+
// pure text chat.
|
|
187
199
|
function hasImages(messages) {
|
|
188
|
-
return messages.
|
|
200
|
+
return messages.slice(-VISION_WINDOW).some(msgHasImage);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// When we DO fall back to the text model, the old image_url blocks must not go
|
|
204
|
+
// with it — a non-vision model either errors on them or silently ignores them
|
|
205
|
+
// while still being billed for the payload. Replace them with a marker so the
|
|
206
|
+
// model knows an image was there rather than seeing a gap it might deny.
|
|
207
|
+
function stripImages(messages) {
|
|
208
|
+
return messages.map((m) => {
|
|
209
|
+
if (!msgHasImage(m)) return m;
|
|
210
|
+
const text = m.content.filter((c) => c?.type === 'text').map((c) => c.text).join(' ').trim();
|
|
211
|
+
return { ...m, content: `${text}${text ? ' ' : ''}[image sent earlier, no longer in context]` };
|
|
212
|
+
});
|
|
189
213
|
}
|
|
190
214
|
|
|
191
215
|
// A non-ok response with no usable content used to silently become '', which
|
|
@@ -243,13 +267,29 @@ async function postChat(body, contextId) {
|
|
|
243
267
|
|
|
244
268
|
/** One openzoo chat turn. Paid per call by the box's own wallet via the local
|
|
245
269
|
* proxy — no key, no account. */
|
|
270
|
+
// Which model actually serves a turn is decided HERE, not by the caller: a
|
|
271
|
+
// thread carrying an image routes to VISION_MODEL. A system prompt written
|
|
272
|
+
// once at thread creation therefore cannot name it correctly, and a bot left
|
|
273
|
+
// to guess asserts whatever its training prior says — MEASURED: a Claude-served
|
|
274
|
+
// turn insisted it was Claude while the app's default is DeepSeek, and burned
|
|
275
|
+
// several paid turns arguing. So the prompt carries a placeholder and the real
|
|
276
|
+
// id is substituted at call time, per turn.
|
|
277
|
+
function withModelId(messages, model) {
|
|
278
|
+
return messages.map((m) => (m.role === 'system' && typeof m.content === 'string' && m.content.includes('__OZ_MODEL__')
|
|
279
|
+
? { ...m, content: m.content.replaceAll('__OZ_MODEL__', model) }
|
|
280
|
+
: m));
|
|
281
|
+
}
|
|
282
|
+
|
|
246
283
|
export async function brain(messages, contextId) {
|
|
247
284
|
// explicit plugins, not relying on the gateway's "inject when caller said
|
|
248
285
|
// nothing" default — an explicit array is always respected as-is, so every
|
|
249
286
|
// bot on every model actually has web search. max_tokens 900 was cutting
|
|
250
287
|
// real (especially web-search-backed) answers off mid-sentence.
|
|
288
|
+
const vision = hasImages(messages);
|
|
289
|
+
const model = vision ? VISION_MODEL : MODEL;
|
|
290
|
+
messages = vision ? messages : stripImages(messages);
|
|
251
291
|
const r = await postChat(
|
|
252
|
-
{ model
|
|
292
|
+
{ model, max_tokens: 4096, messages: withModelId(messages, model), plugins: [{ id: 'web' }] },
|
|
253
293
|
contextId,
|
|
254
294
|
);
|
|
255
295
|
const j = await r.json().catch(() => ({}));
|
|
@@ -261,8 +301,11 @@ export async function brain(messages, contextId) {
|
|
|
261
301
|
* live-typing UI) and resolves with the full accumulated text at the end, so
|
|
262
302
|
* callers that need to parse a directive out of the complete reply still can. */
|
|
263
303
|
export async function brainStream(messages, onDelta, contextId) {
|
|
304
|
+
const vision = hasImages(messages);
|
|
305
|
+
const model = vision ? VISION_MODEL : MODEL;
|
|
306
|
+
messages = vision ? messages : stripImages(messages);
|
|
264
307
|
const r = await postChat(
|
|
265
|
-
{ model
|
|
308
|
+
{ model, max_tokens: 4096, messages: withModelId(messages, model), plugins: [{ id: 'web' }], stream: true },
|
|
266
309
|
contextId,
|
|
267
310
|
);
|
|
268
311
|
if (!r.ok || !r.body) {
|
package/lib/proxy.js
CHANGED
|
@@ -779,6 +779,34 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
|
|
|
779
779
|
server.listen(config.port, bindHost, resolve);
|
|
780
780
|
});
|
|
781
781
|
|
|
782
|
+
// AUTO-PREPAY. Paying on-chain per call is where the latency lives: the
|
|
783
|
+
// gateway answers its 402 challenge in ~0.12s while a full settled call
|
|
784
|
+
// MEASURED 9-37s end to end. Credit is applied automatically server-side
|
|
785
|
+
// whenever a balance covers the quote, so buying it once makes every later
|
|
786
|
+
// call skip verify+settle entirely.
|
|
787
|
+
//
|
|
788
|
+
// Runs in the background — never block the listener on a payment — and only
|
|
789
|
+
// when this wallet actually has funds, so a fresh/empty wallet is untouched.
|
|
790
|
+
// Opt out with OPENZOO_NO_AUTOTOPUP=1; size it with OPENZOO_AUTOTOPUP_USD.
|
|
791
|
+
if (!process.env.OPENZOO_NO_AUTOTOPUP) {
|
|
792
|
+
(async () => {
|
|
793
|
+
try {
|
|
794
|
+
const { creditBalance, topUp } = await import('./info.js');
|
|
795
|
+
const have = await creditBalance();
|
|
796
|
+
const want = Number(process.env.OPENZOO_AUTOTOPUP_USD || 5);
|
|
797
|
+
// Only top up when nearly dry, so restarting the proxy does not keep
|
|
798
|
+
// buying credit on top of a healthy balance.
|
|
799
|
+
if (have >= Math.min(1, want)) return;
|
|
800
|
+
say(`prepaid credit $${have.toFixed(4)} — topping up $${want} so calls stop settling on-chain each time...`);
|
|
801
|
+
await topUp(want);
|
|
802
|
+
} catch (e) {
|
|
803
|
+
// A wallet with no funds, or a gateway that refuses, must never stop
|
|
804
|
+
// the proxy from serving: calls just fall back to paying per call.
|
|
805
|
+
say(`auto top-up skipped: ${String(e.message || e).slice(0, 120)}`);
|
|
806
|
+
}
|
|
807
|
+
})();
|
|
808
|
+
}
|
|
809
|
+
|
|
782
810
|
if (!silent) {
|
|
783
811
|
// VERSION IN THE BANNER, deliberately. `npx openzoo` can serve a STALE
|
|
784
812
|
// cached copy — npx reuses a cache entry that matches the bare spec, so a
|
package/lib/x402.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import {
|
|
2
|
+
ComputeBudgetProgram,
|
|
2
3
|
Connection,
|
|
3
4
|
PublicKey,
|
|
4
5
|
Transaction,
|
|
@@ -153,6 +154,22 @@ export function buildPayment({ accept, decimals, programId, recentBlockhash, key
|
|
|
153
154
|
);
|
|
154
155
|
|
|
155
156
|
const tx = new Transaction({ feePayer, recentBlockhash });
|
|
157
|
+
// UNIQUENESS. Everything else here is a pure function of (amount, accounts,
|
|
158
|
+
// decimals, blockhash), so two calls quoting the same price inside one
|
|
159
|
+
// blockhash window build a BYTE-IDENTICAL transaction — same signature, so
|
|
160
|
+
// the second is a duplicate and the facilitator reports it as
|
|
161
|
+
// `settle {success:false, "Simulation failed ... Logs: []"}`. Concurrent
|
|
162
|
+
// callers hit this constantly (a 10-worker run produced 8 failed_settle in
|
|
163
|
+
// one window) and each failure costs a full retry, which is most of the
|
|
164
|
+
// per-call latency.
|
|
165
|
+
//
|
|
166
|
+
// A random compute-unit LIMIT makes each message distinct at no cost: unlike
|
|
167
|
+
// setComputeUnitPrice it adds no priority fee, and an over-estimate is free
|
|
168
|
+
// — it only caps execution. The range sits far above what a transferChecked
|
|
169
|
+
// plus any funding preInstructions actually consume.
|
|
170
|
+
tx.add(ComputeBudgetProgram.setComputeUnitLimit({
|
|
171
|
+
units: 300_000 + Math.floor(Math.random() * 200_000),
|
|
172
|
+
}));
|
|
156
173
|
for (const pre of preInstructions) tx.add(pre); // internal funding plumbing, if any
|
|
157
174
|
tx.add(ix);
|
|
158
175
|
tx.partialSign(keypair); // owner signs; feePayer slot left empty for the facilitator
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "openzoo",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.45.0",
|
|
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",
|