openzoo 0.9.0 → 0.9.2

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/pay.js CHANGED
@@ -3,7 +3,7 @@ import { getAssociatedTokenAddressSync } from '@solana/spl-token';
3
3
  import { config, fundingLine } from './config.js';
4
4
  import { loadOrCreateWallet } from './wallet.js';
5
5
  import {
6
- parse402, pickAccept, railOf, buildPaymentOnline, tokenBalance,
6
+ parse402, orderAccepts, railOf, buildPaymentOnline, tokenBalance,
7
7
  receiptLine, decodeSettleHeader,
8
8
  } from './x402.js';
9
9
  import { buildEvmPayment, evmTokenBalance } from './evm.js';
@@ -180,15 +180,41 @@ export class PayClient {
180
180
 
181
181
  const quote = parse402(await first.json());
182
182
  // config.rail (OPENZOO_RAIL) steers every front — proxy, demo, MCP — since
183
- // they all pay through this one call site.
184
- const accept = pickAccept(quote, config.token, { allowRH: this.allowRH, forceRail: config.rail });
185
- const billedUsd = Number(accept?.extra?.billedUsd ?? NaN);
186
- if (Number.isFinite(billedUsd) && billedUsd > config.maxUsdPerCall) {
187
- throw new QuoteTooHighError(billedUsd, quote);
188
- }
189
-
183
+ // they all pay through this one call site. The wallet pays with whatever
184
+ // it HOLDS: every offered row is tried best-first, and only when NONE is
185
+ // affordable does the call fail — never because the first-choice asset
186
+ // alone ran dry while another funded one sat in the wallet.
187
+ const candidates = orderAccepts(quote, config.token, { allowRH: this.allowRH, forceRail: config.rail });
190
188
  onStage?.('quoted');
191
- const payment = await this.buildPaymentFor(accept, onStage);
189
+ let accept = null;
190
+ let payment = null;
191
+ const fundErrs = [];
192
+ let tooHigh = null;
193
+ for (const cand of candidates) {
194
+ const billedUsd = Number(cand?.extra?.billedUsd ?? NaN);
195
+ if (Number.isFinite(billedUsd) && billedUsd > config.maxUsdPerCall) {
196
+ tooHigh = tooHigh || new QuoteTooHighError(billedUsd, quote);
197
+ continue;
198
+ }
199
+ try {
200
+ payment = await this.buildPaymentFor(cand, onStage);
201
+ accept = cand;
202
+ break;
203
+ } catch (e) {
204
+ const funding = e instanceof UnderfundedError
205
+ || e?.name === 'UnderlyingShortError' || e?.name === 'NeedsGasError';
206
+ if (!funding) throw e;
207
+ fundErrs.push({ sym: cand?.extra?.symbol || cand?.asset, err: e });
208
+ }
209
+ }
210
+ if (!payment) {
211
+ if (!fundErrs.length && tooHigh) throw tooHigh;
212
+ if (fundErrs.length === 1) throw fundErrs[0].err;
213
+ throw new UnderfundedError(candidates[0], null, this.address, {
214
+ line: `No offered payment row is affordable from this wallet (tried ${fundErrs.length}):\n`
215
+ + fundErrs.map(({ sym, err }) => ` · ${sym}: ${err.message.replace(/^openzoo wallet underfunded: /, '')}`).join('\n'),
216
+ });
217
+ }
192
218
  onStage?.('paying');
193
219
  const response = await fetch(url, {
194
220
  ...init,
package/lib/proxy.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import http from 'node:http';
2
+ import crypto from 'node:crypto';
2
3
  import { Readable } from 'node:stream';
3
4
  import {
4
5
  config, FUNDING_ASSETS, fundingLine, liveRails, railFundingHint, railFundingAddresses, unfundableRails, RAIL_FUNDING,
@@ -55,6 +56,64 @@ function jsonErr(res, status, message, extraFields = {}) {
55
56
 
56
57
  const mb = (n) => (n / 1048576).toFixed(1);
57
58
 
59
+ /**
60
+ * The zoo answers chat completions as ONE JSON object (it settles payment
61
+ * before serving — there is nothing to stream until generation is done).
62
+ * Harnesses that sent `stream: true` expect SSE and treat a JSON body as a
63
+ * dead connection: Cursor shows "Reconnecting…", RETRIES, and every retry is
64
+ * a fresh payment. So the proxy honours the contract itself — the finished
65
+ * completion is re-emitted as spec-shaped chat.completion.chunk events.
66
+ */
67
+ function serveAsSse(res, data, upstream) {
68
+ const headers = {
69
+ 'content-type': 'text/event-stream; charset=utf-8',
70
+ 'cache-control': 'no-cache',
71
+ };
72
+ const settle = upstream?.headers?.get?.('x-payment-response');
73
+ if (settle) headers['x-payment-response'] = settle;
74
+ res.writeHead(200, headers);
75
+ const base = {
76
+ id: data.id, object: 'chat.completion.chunk', created: data.created, model: data.model,
77
+ };
78
+ const ev = (obj) => res.write(`data: ${JSON.stringify(obj)}\n\n`);
79
+ for (const c of data.choices || []) {
80
+ ev({ ...base, choices: [{ index: c.index ?? 0, delta: { role: 'assistant' }, finish_reason: null }] });
81
+ if (c.message?.content) {
82
+ ev({ ...base, choices: [{ index: c.index ?? 0, delta: { content: c.message.content }, finish_reason: null }] });
83
+ }
84
+ ev({ ...base, choices: [{ index: c.index ?? 0, delta: {}, finish_reason: c.finish_reason ?? 'stop' }], ...(data.usage ? { usage: data.usage } : {}) });
85
+ }
86
+ res.write('data: [DONE]\n\n');
87
+ res.end();
88
+ }
89
+
90
+ /**
91
+ * Replay guard — a harness that cannot consume a response retries the SAME
92
+ * body within seconds, and each retry used to be a fresh payment (observed:
93
+ * six identical $0.06 settles for one Cursor message). An identical POST body
94
+ * arriving within the window is served the cached completion, not re-paid.
95
+ * Window is deliberately short: a genuinely new turn always differs (harnesses
96
+ * resend the whole conversation), so only true retries can hit.
97
+ */
98
+ const REPLAY_TTL_MS = 30_000;
99
+ const replayCache = new Map(); // sha256(body) -> { at, data, settle }
100
+ function replayKey(bodyBuf) {
101
+ return crypto.createHash('sha256').update(bodyBuf).digest('hex');
102
+ }
103
+ function replayGet(key) {
104
+ const hit = replayCache.get(key);
105
+ if (!hit) return null;
106
+ if (Date.now() - hit.at > REPLAY_TTL_MS) { replayCache.delete(key); return null; }
107
+ return hit;
108
+ }
109
+ function replayPut(key, data, settle) {
110
+ replayCache.set(key, { at: Date.now(), data, settle });
111
+ if (replayCache.size > 50) {
112
+ const oldest = [...replayCache.entries()].sort((a, b) => a[1].at - b[1].at)[0];
113
+ if (oldest) replayCache.delete(oldest[0]);
114
+ }
115
+ }
116
+
58
117
  /**
59
118
  * "The body never ships twice" at the proxy. A chat body whose LAST message
60
119
  * carries a huge pasted corpus gets split at its last blank line — corpus vs
@@ -164,12 +223,31 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
164
223
  // NEAREST zoo model BEFORE anything else sees the body — any POST that
165
224
  // carries a model field, not just chat/completions, so /completions,
166
225
  // /responses and future shapes all work. Never silent.
226
+ let wantsStream = false;
167
227
  if (rewritablePath(req.method, req.url)) {
168
228
  const rw = await maybeRewriteModel(bodyBuf);
169
229
  if (rw) {
170
230
  log(`model "${rw.from}" is not on the zoo — nearest match ${rw.to} (OPENZOO_DEFAULT_MODEL overrides)`);
171
231
  bodyBuf = rw.body;
172
232
  }
233
+ try { wantsStream = JSON.parse(bodyBuf.toString('utf8'))?.stream === true; } catch { /* not JSON */ }
234
+ }
235
+
236
+ // Retry of a body we answered seconds ago? Serve the cached completion —
237
+ // never pay twice for a harness's reconnect loop.
238
+ const isChat = req.method === 'POST' && (req.url || '').includes('/chat/completions');
239
+ const rKey = isChat ? replayKey(bodyBuf) : null;
240
+ if (rKey) {
241
+ const hit = replayGet(rKey);
242
+ if (hit) {
243
+ log('identical request within 30s — served the cached completion, NOT re-paid');
244
+ if (wantsStream) { serveAsSse(res, hit.data, null); return; }
245
+ const h = { 'content-type': 'application/json' };
246
+ if (hit.settle) h['x-payment-response'] = hit.settle;
247
+ res.writeHead(200, h);
248
+ res.end(JSON.stringify(hit.data));
249
+ return;
250
+ }
173
251
  }
174
252
  const init = { method: req.method, headers: upstreamHeaders(req) };
175
253
  if (req.method !== 'GET' && req.method !== 'HEAD') init.body = bodyBuf;
@@ -242,6 +320,25 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
242
320
  else if (viaTunnel) log(`${line} · public-url session $${tunnelSpent.toFixed(6)}`);
243
321
  else log(line);
244
322
  }
323
+ // Chat completions come back as one JSON object (settle-before-serve).
324
+ // Cache it against retries, and if the harness asked to stream, honour
325
+ // that contract ourselves. An upstream that someday truly streams (SSE
326
+ // content-type) passes straight through the relay below, untouched.
327
+ const upCt = response.headers.get('content-type') || '';
328
+ if (isChat && response.ok && upCt.includes('application/json')) {
329
+ let data = null;
330
+ try { data = await response.clone().json(); } catch { /* not JSON after all */ }
331
+ if (data?.object === 'chat.completion') {
332
+ if (rKey) replayPut(rKey, data, response.headers.get('x-payment-response'));
333
+ if (wantsStream) { serveAsSse(res, data, response); return; }
334
+ const h = { 'content-type': 'application/json' };
335
+ const settleHdr = response.headers.get('x-payment-response');
336
+ if (settleHdr) h['x-payment-response'] = settleHdr;
337
+ res.writeHead(200, h);
338
+ res.end(JSON.stringify(data));
339
+ return;
340
+ }
341
+ }
245
342
  await relay(res, response);
246
343
  } catch (err) {
247
344
  if (err instanceof QuoteTooHighError) {
package/lib/x402.js CHANGED
@@ -74,8 +74,20 @@ export function evmChainId(network) {
74
74
  * but RH stays opt-in for DEFAULT selection because its settlement asset has
75
75
  * no auto-conversion path here.
76
76
  */
77
- export function pickAccept(body, preferredSymbol, { allowRH = false, forceRail = null } = {}) {
77
+ /**
78
+ * ALL payable rows from a 402, best-first. The wallet pays with whatever it
79
+ * HOLDS — the caller walks this list and takes the first affordable row, so
80
+ * a wallet rich in TOKEN but short of USDC pays the TOKEN row instead of
81
+ * erroring on the USDC one. Preference order within the list: the preferred
82
+ * symbol, then the rest of Solana (sponsored fees), then Base, then Robinhood
83
+ * (gated — paying there costs the wallet its own gas).
84
+ */
85
+ export function orderAccepts(body, preferredSymbol, { allowRH = false, forceRail = null } = {}) {
78
86
  const rows = parse402(body).accepts.filter((a) => a?.scheme === 'exact');
87
+ const bySym = (list) => [
88
+ ...list.filter((a) => a?.extra?.symbol === preferredSymbol),
89
+ ...list.filter((a) => a?.extra?.symbol !== preferredSymbol),
90
+ ];
79
91
  if (forceRail) {
80
92
  const want = String(forceRail).toLowerCase();
81
93
  if (!['solana', 'base', 'robinhood', 'evm'].includes(want)) {
@@ -88,19 +100,24 @@ export function pickAccept(body, preferredSymbol, { allowRH = false, forceRail =
88
100
  `OPENZOO_RAIL=${want} but the live 402 offers no ${want} rail (offered: ${offered.join(', ') || 'none'})`,
89
101
  );
90
102
  }
91
- return match.find((a) => a?.extra?.symbol === preferredSymbol) || match[0];
103
+ return bySym(match);
104
+ }
105
+ const out = [
106
+ ...bySym(rows.filter((a) => railOf(a) === 'solana')),
107
+ ...rows.filter((a) => railOf(a) === 'base'),
108
+ ...rows.filter((a) => railOf(a) === 'evm'),
109
+ ...(allowRH ? rows.filter((a) => railOf(a) === 'robinhood') : []),
110
+ ];
111
+ if (!out.length) {
112
+ throw new Error(rows.some((a) => railOf(a) === 'robinhood')
113
+ ? 'only Robinhood Chain rails offered — set OPENZOO_ENABLE_RH=1 or OPENZOO_RAIL=robinhood to use them (the rail settles; you must hold its settlement asset, see https://x402.accrue.fund/start)'
114
+ : 'no payable rail in 402 accepts[]');
92
115
  }
93
- const sol = rows.filter((a) => railOf(a) === 'solana');
94
- if (sol.length) return sol.find((a) => a?.extra?.symbol === preferredSymbol) || sol[0];
95
- const base = rows.filter((a) => railOf(a) === 'base');
96
- if (base.length) return base[0];
97
- const evm = rows.filter((a) => railOf(a) === 'evm');
98
- if (evm.length) return evm[0];
99
- const rh = rows.filter((a) => railOf(a) === 'robinhood');
100
- if (allowRH && rh.length) return rh[0];
101
- throw new Error(rh.length
102
- ? 'only Robinhood Chain rails offered — set OPENZOO_ENABLE_RH=1 or OPENZOO_RAIL=robinhood to use them (the rail settles; you must hold its settlement asset, see https://x402.accrue.fund/start)'
103
- : 'no payable rail in 402 accepts[]');
116
+ return out;
117
+ }
118
+
119
+ export function pickAccept(body, preferredSymbol, opts = {}) {
120
+ return orderAccepts(body, preferredSymbol, opts)[0];
104
121
  }
105
122
 
106
123
  const mintCache = new Map();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openzoo",
3
- "version": "0.9.0",
3
+ "version": "0.9.2",
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",