openzoo 0.9.2 → 0.9.4

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.
Files changed (2) hide show
  1. package/lib/proxy.js +101 -7
  2. package/package.json +1 -1
package/lib/proxy.js CHANGED
@@ -2,10 +2,11 @@ import http from 'node:http';
2
2
  import crypto from 'node:crypto';
3
3
  import { Readable } from 'node:stream';
4
4
  import {
5
- config, FUNDING_ASSETS, fundingLine, liveRails, railFundingHint, railFundingAddresses, unfundableRails, RAIL_FUNDING,
5
+ config, FUNDING_ASSETS, EVM_FUNDING_ASSETS, evmRpcFor, fundingLine, liveRails, railFundingHint, railFundingAddresses, unfundableRails, RAIL_FUNDING,
6
6
  } from './config.js';
7
7
  import { PayClient, QuoteTooHighError, UnderfundedError } from './pay.js';
8
8
  import { tokenBalance } from './x402.js';
9
+ import { evmTokenBalance } from './evm.js';
9
10
  import { bindCorpus, contextCacheDisabled, BIND_MIN_CHARS } from './hrr.js';
10
11
  import { maybeRewriteModel, rewritablePath, augmentModelList, ALIAS_IDS } from './models.js';
11
12
  import { forgetContext } from './contexts.js';
@@ -56,6 +57,42 @@ function jsonErr(res, status, message, extraFields = {}) {
56
57
 
57
58
  const mb = (n) => (n / 1048576).toFixed(1);
58
59
 
60
+ /**
61
+ * Every fundable balance across all three chains, for the startup line and
62
+ * the live refresh. Each read is independent and advisory — one lagging RPC
63
+ * drops its entry rather than blanking the whole line.
64
+ */
65
+ async function snapshotBalances(client) {
66
+ const out = [];
67
+ try {
68
+ const bals = await Promise.all(
69
+ FUNDING_ASSETS.map((a) => tokenBalance(client.connection, client.keypair.publicKey, a.mint)),
70
+ );
71
+ FUNDING_ASSETS.forEach((a, i) => out.push({ symbol: a.symbol, ui: Number(bals[i].ui ?? 0), chain: 'solana' }));
72
+ } catch { /* Solana RPC hiccup — EVM entries still report */ }
73
+ const owner = client.evmAddress;
74
+ if (owner) {
75
+ await Promise.all(Object.entries(EVM_FUNDING_ASSETS).flatMap(([rail, assets]) => assets.map(async (a) => {
76
+ try {
77
+ const raw = await evmTokenBalance({ rpcUrl: evmRpcFor(rail), token: a.address, owner });
78
+ out.push({ symbol: a.symbol, ui: Number(raw) / 10 ** a.decimals, chain: rail });
79
+ } catch { /* advisory */ }
80
+ })));
81
+ }
82
+ // Parallel reads land in racy order; sort so the printed line is stable
83
+ // and diffs against the previous snapshot read cleanly.
84
+ const rank = { solana: 0, base: 1, robinhood: 2 };
85
+ return out.sort((a, b) => (rank[a.chain] ?? 9) - (rank[b.chain] ?? 9) || a.symbol.localeCompare(b.symbol));
86
+ }
87
+
88
+ /** Solana entries always show; EVM entries only once they hold something. */
89
+ function balanceLine(snap) {
90
+ return snap
91
+ .filter((b) => b.chain === 'solana' || b.ui > 0)
92
+ .map((b) => `${b.ui} ${b.symbol}${b.chain !== 'solana' ? ` (${b.chain})` : ''}`)
93
+ .join(' · ');
94
+ }
95
+
59
96
  /**
60
97
  * The zoo answers chat completions as ONE JSON object (it settles payment
61
98
  * before serving — there is nothing to stream until generation is done).
@@ -81,6 +118,27 @@ function serveAsSse(res, data, upstream) {
81
118
  if (c.message?.content) {
82
119
  ev({ ...base, choices: [{ index: c.index ?? 0, delta: { content: c.message.content }, finish_reason: null }] });
83
120
  }
121
+ // Agent mode lives or dies here: a finish_reason of "tool_calls" with the
122
+ // calls themselves dropped strands the harness mid-turn (observed: Cursor
123
+ // agent hangs). Streaming spec: tool_calls ride the delta with an index,
124
+ // arguments as a string chunk — one full chunk per call is valid SSE.
125
+ if (Array.isArray(c.message?.tool_calls) && c.message.tool_calls.length) {
126
+ ev({
127
+ ...base,
128
+ choices: [{
129
+ index: c.index ?? 0,
130
+ delta: {
131
+ tool_calls: c.message.tool_calls.map((t, i) => ({
132
+ index: i,
133
+ id: t.id,
134
+ type: t.type || 'function',
135
+ function: { name: t.function?.name, arguments: t.function?.arguments ?? '' },
136
+ })),
137
+ },
138
+ finish_reason: null,
139
+ }],
140
+ });
141
+ }
84
142
  ev({ ...base, choices: [{ index: c.index ?? 0, delta: {}, finish_reason: c.finish_reason ?? 'stop' }], ...(data.usage ? { usage: data.usage } : {}) });
85
143
  }
86
144
  res.write('data: [DONE]\n\n');
@@ -175,6 +233,17 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
175
233
  const log = silent ? () => {} : (...a) => console.log(...a);
176
234
  let sessionSpent = 0;
177
235
  let tunnelSpent = 0;
236
+ // Live balance refresh state — the real implementation is assigned in the
237
+ // banner section below; the handler only ever calls scheduleRefresh().
238
+ let lastSnap = null;
239
+ let refreshBalances = async () => {};
240
+ let refreshPending = false;
241
+ const scheduleRefresh = (ms) => {
242
+ if (silent || refreshPending) return;
243
+ refreshPending = true;
244
+ const t = setTimeout(async () => { refreshPending = false; await refreshBalances(); }, ms);
245
+ t.unref?.();
246
+ };
178
247
  // Set once cloudflared is up (see below). Gating keys off the REQUEST's
179
248
  // origin, not off whether the URL exists yet, so there is no startup window
180
249
  // where public traffic slips through ungated.
@@ -319,6 +388,7 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
319
388
  if (requireToken) log(`${line} · session $${sessionSpent.toFixed(6)}`);
320
389
  else if (viaTunnel) log(`${line} · public-url session $${tunnelSpent.toFixed(6)}`);
321
390
  else log(line);
391
+ scheduleRefresh(4000); // settlement lands on-chain in a few seconds
322
392
  }
323
393
  // Chat completions come back as one JSON object (settle-before-serve).
324
394
  // Cache it against retries, and if the harness asked to stream, honour
@@ -366,15 +436,39 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
366
436
  console.log(`wallet (fund me) · solana: ${client.address}`);
367
437
  if (client.evmAddress) console.log(`wallet (fund me) · evm (base / robinhood): ${client.evmAddress}`);
368
438
  try {
369
- const bals = await Promise.all(
370
- FUNDING_ASSETS.map((a) => tokenBalance(client.connection, client.keypair.publicKey, a.mint)),
371
- );
372
- const parts = FUNDING_ASSETS.map((a, i) => `${bals[i].ui ?? 0} ${a.symbol}`);
373
- console.log(`balance: ${parts.join(' · ')}`);
374
- if (!bals.some((b) => b.raw)) {
439
+ lastSnap = await snapshotBalances(client);
440
+ console.log(`balance: ${balanceLine(lastSnap) || '(no RPC reachable — advisory only)'}`);
441
+ if (!lastSnap.some((b) => b.ui > 0)) {
375
442
  console.log(`fund it: ${fundingLine('the address above')} — a few cents goes a long way.`);
376
443
  }
377
444
  } catch { /* RPC hiccup: balance is advisory */ }
445
+ // LIVE REFRESH: the startup line goes stale the moment a call settles or
446
+ // the user funds mid-session. Poll on an interval (and shortly after each
447
+ // paid call), print ONLY on change, and call out arrivals explicitly so
448
+ // "did my top-up land?" answers itself in the running log.
449
+ refreshBalances = async () => {
450
+ try {
451
+ const snap = await snapshotBalances(client);
452
+ if (!snap.length) return;
453
+ const prev = new Map((lastSnap || []).map((b) => [`${b.chain}:${b.symbol}`, b.ui]));
454
+ const changed = snap.some((b) => Math.abs((prev.get(`${b.chain}:${b.symbol}`) ?? 0) - b.ui) > 1e-9)
455
+ || snap.length !== (lastSnap || []).length;
456
+ if (!changed) return;
457
+ if (lastSnap) {
458
+ for (const b of snap) {
459
+ const gain = b.ui - (prev.get(`${b.chain}:${b.symbol}`) ?? 0);
460
+ if (gain > 1e-9) console.log(`funding arrived: +${gain.toFixed(6)} ${b.symbol}${b.chain !== 'solana' ? ` (${b.chain})` : ''}`);
461
+ }
462
+ }
463
+ lastSnap = snap;
464
+ console.log(`balance: ${balanceLine(snap)}`);
465
+ } catch { /* advisory — never noisy on RPC trouble */ }
466
+ };
467
+ const pollSecs = Number(process.env.OPENZOO_BALANCE_POLL_SECS ?? 45);
468
+ if (pollSecs > 0) {
469
+ const timer = setInterval(refreshBalances, pollSecs * 1000);
470
+ timer.unref?.();
471
+ }
378
472
  // Which rails the zoo will actually settle right now, straight off a live
379
473
  // 402 — so nobody funds a lane the resource is not currently offering.
380
474
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openzoo",
3
- "version": "0.9.2",
3
+ "version": "0.9.4",
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",