openzoo 0.9.0 → 0.9.1
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/proxy.js +97 -0
- package/package.json +1 -1
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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "openzoo",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.1",
|
|
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",
|