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 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 runMatch = /^RUN:\s*([\s\S]+)/.exec(r);
417
- if (runMatch) {
418
- const command = runMatch[1].trim();
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 runMatch = /^RUN:\s*([\s\S]+)/.exec(reply);
461
- if (runMatch) {
462
- const command = runMatch[1].trim();
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
- #hudBtn { margin-left: auto; }
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 &lt;path&gt; 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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;' }[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.some((m) => Array.isArray(m.content) && m.content.some((c) => c?.type === 'image_url'));
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: hasImages(messages) ? VISION_MODEL : MODEL, max_tokens: 4096, messages, plugins: [{ id: 'web' }] },
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: hasImages(messages) ? VISION_MODEL : MODEL, max_tokens: 4096, messages, plugins: [{ id: 'web' }], stream: true },
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.44.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",