openzoo 0.49.18 → 0.50.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/xburner.js ADDED
@@ -0,0 +1,80 @@
1
+ /**
2
+ * A managed x402 burner per X account, DERIVED not stored.
3
+ *
4
+ * @openzoobot's paid lane needs the ASKER to pay, and an X reply is a terrible
5
+ * place to ask someone to install a wallet. So each X account id gets a burner
6
+ * the zoo drives on their behalf, funded by them and auto-topped-up, holding
7
+ * only a working balance — the same shape as the local burner `npx openzoo`
8
+ * already creates, one per account instead of one per machine.
9
+ *
10
+ * DERIVED, NOT STORED, and that is the whole security argument:
11
+ * seed(user) = HMAC-SHA512(master, "openzoo-xbot-v1:" + userId)
12
+ * There is exactly ONE secret on disk no matter how many accounts ever mention
13
+ * the bot. A per-user keyfile store would mean thousands of secrets, a backup
14
+ * problem, a deletion problem, and a breach that scales with adoption. Here the
15
+ * blast radius is one file that already had to be protected, and a burner can
16
+ * be re-derived on any machine from that file alone — nothing to lose, nothing
17
+ * to migrate, no keypair that exists only on whichever laptop ran the poller.
18
+ *
19
+ * The tradeoff, stated plainly: the master file CAN derive every burner, so it
20
+ * is as sensitive as all of them combined. That is why it is 0600, never
21
+ * logged, never sent anywhere, and why balances are kept at working size by
22
+ * auto top-up rather than being allowed to accumulate.
23
+ */
24
+
25
+ import fs from 'node:fs';
26
+ import os from 'node:os';
27
+ import path from 'node:path';
28
+ import crypto from 'node:crypto';
29
+ import { Keypair } from '@solana/web3.js';
30
+
31
+ const MASTER_FILE = process.env.OPENZOO_XBOT_MASTER
32
+ || path.join(os.homedir(), '.openzoo', 'xbot-master.key');
33
+
34
+ /** Bump if the derivation ever changes — old burners must keep deriving. */
35
+ const DERIVATION = 'openzoo-xbot-v1';
36
+
37
+ export function loadOrCreateMaster(file = MASTER_FILE) {
38
+ try {
39
+ const hex = fs.readFileSync(file, 'utf8').trim();
40
+ const buf = Buffer.from(hex, 'hex');
41
+ if (buf.length === 32) return buf;
42
+ throw new Error('bad length');
43
+ } catch {
44
+ const buf = crypto.randomBytes(32);
45
+ fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
46
+ // wx: never clobber an existing master. Overwriting it would orphan every
47
+ // burner ever handed out — funds still on-chain, key unrecoverable.
48
+ try {
49
+ fs.writeFileSync(file, buf.toString('hex') + '\n', { mode: 0o600, flag: 'wx' });
50
+ } catch {
51
+ return Buffer.from(fs.readFileSync(file, 'utf8').trim(), 'hex');
52
+ }
53
+ return buf;
54
+ }
55
+ }
56
+
57
+ /**
58
+ * Deterministic burner for an X user id.
59
+ * Keyed on the numeric id, never the handle: handles are reassignable, and a
60
+ * burner that follows a renamed handle would hand a new owner the old wallet.
61
+ */
62
+ export function deriveBurner(xUserId, master = loadOrCreateMaster()) {
63
+ if (!xUserId) throw new Error('deriveBurner needs an X user id');
64
+ const mac = crypto.createHmac('sha512', master)
65
+ .update(`${DERIVATION}:${String(xUserId)}`)
66
+ .digest();
67
+ const keypair = Keypair.fromSeed(Uint8Array.from(mac.subarray(0, 32)));
68
+ const evmPrivateKey = `0x${mac.subarray(32, 64).toString('hex')}`;
69
+ return {
70
+ keypair,
71
+ evmPrivateKey,
72
+ address: keypair.publicKey.toBase58(),
73
+ xUserId: String(xUserId),
74
+ };
75
+ }
76
+
77
+ /** Address only — for a reply that tells someone where to send funds. */
78
+ export function burnerAddress(xUserId, master) {
79
+ return deriveBurner(xUserId, master).address;
80
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "openzoo",
3
- "version": "0.49.18",
4
- "description": "Local x402-paying proxy + MCP server for openzoo.fun \u2014 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.",
3
+ "version": "0.50.1",
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",
7
7
  "bin": {
@@ -10,14 +10,13 @@
10
10
  "files": [
11
11
  "bin",
12
12
  "lib",
13
- "README.md",
14
- "vendor/modelroute"
13
+ "README.md"
15
14
  ],
16
15
  "engines": {
17
16
  "node": ">=18"
18
17
  },
19
18
  "scripts": {
20
- "test": "node --test test/*.test.js && node scripts/assert-grokui-pin.mjs && node scripts/assert-app-html-script.mjs && node scripts/assert-overlaid-openzoo.mjs"
19
+ "test": "node --test test/*.test.js"
21
20
  },
22
21
  "dependencies": {
23
22
  "@modelcontextprotocol/sdk": "^1.12.0",
package/lib/anthropic.js DELETED
@@ -1,369 +0,0 @@
1
- /**
2
- * Anthropic Messages API shape, served by the openzoo proxy.
3
- *
4
- * WHY: harnesses that speak Anthropic (Claude Code via ANTHROPIC_BASE_URL, the
5
- * Anthropic SDKs) could not use the zoo at all — it speaks OpenAI chat
6
- * completions. Pointing DNS or /etc/hosts at localhost does not work: the TLS
7
- * cert will not match and the client refuses the connection. A translating
8
- * endpoint is the only mechanism that actually routes such a harness through
9
- * x402 payment, and it needs no system changes.
10
- *
11
- * Translation is deliberately conservative: what maps cleanly is mapped, and
12
- * anything unrecognised is passed through rather than dropped, so a field this
13
- * file has never heard of still reaches the model.
14
- */
15
-
16
- /** Anthropic content blocks -> an OpenAI message content value. */
17
- function blocksToOpenAI(content) {
18
- if (typeof content === 'string') return content;
19
- if (!Array.isArray(content)) return '';
20
- const parts = [];
21
- for (const b of content) {
22
- if (b?.type === 'text') parts.push({ type: 'text', text: b.text ?? '' });
23
- else if (b?.type === 'image' && b.source?.type === 'base64') {
24
- parts.push({
25
- type: 'image_url',
26
- image_url: { url: `data:${b.source.media_type};base64,${b.source.data}` },
27
- });
28
- }
29
- }
30
- if (parts.length === 1 && parts[0].type === 'text') return parts[0].text;
31
- return parts.length ? parts : '';
32
- }
33
-
34
- /**
35
- * Anthropic request -> OpenAI request.
36
- *
37
- * The two shapes disagree on three things that matter: `system` is a top-level
38
- * field (OpenAI wants a system MESSAGE), tool results are user-turn blocks
39
- * (OpenAI wants role:"tool" messages), and tool schemas live under
40
- * `input_schema` rather than `parameters`.
41
- */
42
- export function anthropicToOpenAI(body) {
43
- const messages = [];
44
- if (body.system) {
45
- const text = typeof body.system === 'string'
46
- ? body.system
47
- : (Array.isArray(body.system) ? body.system.map((b) => b?.text ?? '').join('\n') : '');
48
- if (text) messages.push({ role: 'system', content: text });
49
- }
50
-
51
- for (const m of body.messages ?? []) {
52
- const blocks = Array.isArray(m.content) ? m.content : null;
53
- const toolResults = blocks?.filter((b) => b?.type === 'tool_result') ?? [];
54
- const toolUses = blocks?.filter((b) => b?.type === 'tool_use') ?? [];
55
-
56
- // A user turn carrying tool_result blocks becomes one OpenAI tool message
57
- // per result — they are answers to specific calls, not prose.
58
- for (const tr of toolResults) {
59
- messages.push({
60
- role: 'tool',
61
- tool_call_id: tr.tool_use_id,
62
- content: typeof tr.content === 'string' ? tr.content : JSON.stringify(tr.content ?? ''),
63
- });
64
- }
65
-
66
- if (toolUses.length) {
67
- messages.push({
68
- role: 'assistant',
69
- content: blocksToOpenAI(blocks.filter((b) => b?.type === 'text')) || null,
70
- tool_calls: toolUses.map((t) => ({
71
- id: t.id,
72
- type: 'function',
73
- function: { name: t.name, arguments: JSON.stringify(t.input ?? {}) },
74
- })),
75
- });
76
- continue;
77
- }
78
-
79
- const rest = blocks ? blocks.filter((b) => b?.type !== 'tool_result') : m.content;
80
- const content = blocksToOpenAI(rest);
81
- if (content && (!Array.isArray(content) || content.length)) {
82
- messages.push({ role: m.role, content });
83
- }
84
- }
85
-
86
- const out = { ...body, messages };
87
- delete out.system;
88
- delete out.anthropic_version;
89
- delete out.metadata;
90
- // A SERVER-SIDE web_search TOOL IS A PLUGIN, NOT A FUNCTION — TRANSLATE IT.
91
- //
92
- // Claude Code's Web Search arrives as an Anthropic server tool (type
93
- // "web_search_20250305", name "web_search") with NO input_schema, because
94
- // Anthropic runs it itself. Routed to OpenRouter->some other provider, nobody
95
- // runs it, and the old code simply DROPPED it (no schema) — so the model got
96
- // no search capability and every Web Search came back "0 results". Silent,
97
- // and worse than the 400 it replaced: the agent believes it searched.
98
- //
99
- // OpenRouter's `web` plugin is the cross-model equivalent — search-then-inject
100
- // middleware that works on every model. Detect the server tool and switch it
101
- // on, mapping max_uses -> max_results, rather than discarding the intent.
102
- const webTool = (Array.isArray(body.tools) ? body.tools : [])
103
- .find((t) => t?.type?.startsWith?.('web_search') || t?.name === 'web_search');
104
- if (webTool) {
105
- const existing = Array.isArray(body.plugins) ? body.plugins : [];
106
- if (!existing.some((p) => p?.id === 'web')) {
107
- const web = { id: 'web' };
108
- if (Number.isFinite(webTool.max_uses)) web.max_results = webTool.max_uses;
109
- out.plugins = [...existing, web];
110
- }
111
- }
112
- if (Array.isArray(body.tools) && body.tools.length) {
113
- out.tools = body.tools
114
- .filter((t) => t?.name && t?.input_schema)
115
- .map((t) => ({
116
- type: 'function',
117
- function: { name: t.name, description: t.description, parameters: t.input_schema },
118
- }));
119
- if (!out.tools.length) delete out.tools;
120
- }
121
- // TOOL_CHOICE MAY NOT OUTLIVE TOOLS.
122
- //
123
- // The block above DROPS any tool without a name+input_schema — which is every
124
- // server-side tool (web_search, computer, bash), because those carry no schema
125
- // in the Anthropic shape. When that empties the list, `out.tools` is deleted.
126
- // But tool_choice was set unconditionally, so the body went upstream with a
127
- // choice and nothing to choose from.
128
- //
129
- // OBSERVED on grok-4.6: a Claude Code Web Search 400'd with "A tool_choice was
130
- // set on the request but no tools were specified." Every provider rejects this
131
- // shape; it just words it differently. Gate the choice on tools surviving.
132
- const hasTools = Array.isArray(out.tools) && out.tools.length > 0;
133
- if (!hasTools) {
134
- delete out.tool_choice;
135
- } else if (body.tool_choice?.type === 'auto') out.tool_choice = 'auto';
136
- else if (body.tool_choice?.type === 'any') out.tool_choice = 'required';
137
- else if (body.tool_choice?.type === 'tool') {
138
- out.tool_choice = { type: 'function', function: { name: body.tool_choice.name } };
139
- }
140
- if (body.stop_sequences) { out.stop = body.stop_sequences; delete out.stop_sequences; }
141
- return out;
142
- }
143
-
144
- const STOP_REASON = {
145
- stop: 'end_turn', length: 'max_tokens', tool_calls: 'tool_use', content_filter: 'stop_sequence',
146
- };
147
-
148
- /** OpenAI completion -> Anthropic message. */
149
- export function openAIToAnthropic(data, requestedModel) {
150
- const choice = (data.choices ?? [])[0] ?? {};
151
- const msg = choice.message ?? {};
152
- const content = [];
153
- if (msg.content) content.push({ type: 'text', text: msg.content });
154
- // Same reason as the streaming path: a refusal arrives with `content: null`
155
- // and the sentence in `refusal`, so dropping it yields an empty message that
156
- // reads as a broken model rather than as the answer it is.
157
- else if (typeof msg.refusal === 'string' && msg.refusal.length) {
158
- content.push({ type: 'text', text: msg.refusal });
159
- }
160
- for (const t of msg.tool_calls ?? []) {
161
- let input = {};
162
- try { input = JSON.parse(t.function?.arguments || '{}'); } catch { input = {}; }
163
- content.push({ type: 'tool_use', id: t.id, name: t.function?.name, input });
164
- }
165
- return {
166
- id: data.id ?? `msg_${Date.now()}`,
167
- type: 'message',
168
- role: 'assistant',
169
- model: requestedModel ?? data.model,
170
- content,
171
- stop_reason: STOP_REASON[choice.finish_reason] ?? 'end_turn',
172
- stop_sequence: null,
173
- usage: {
174
- input_tokens: data.usage?.prompt_tokens ?? 0,
175
- output_tokens: data.usage?.completion_tokens ?? 0,
176
- },
177
- };
178
- }
179
-
180
- /**
181
- * Emit a finished completion as an Anthropic SSE stream.
182
- *
183
- * The zoo settles payment before it answers, so there is nothing to stream
184
- * until generation is done — same reason the OpenAI path re-emits chunks. A
185
- * client that asked for a stream and got a JSON body treats the connection as
186
- * dead and RETRIES, and every retry is another payment.
187
- */
188
- export function writeAnthropicSse(res, message, upstream) {
189
- // x-accel-buffering: no keeps the cloudflare tunnel / nginx from buffering
190
- // the stream and merging the tool_use frames a client parses incrementally.
191
- const headers = {
192
- 'content-type': 'text/event-stream; charset=utf-8',
193
- 'cache-control': 'no-cache',
194
- 'x-accel-buffering': 'no',
195
- connection: 'keep-alive',
196
- };
197
- const settle = upstream?.headers?.get?.('x-payment-response');
198
- if (settle) headers['x-payment-response'] = settle;
199
- res.writeHead(200, headers);
200
- const ev = (type, data) => res.write(`event: ${type}\ndata: ${JSON.stringify({ type, ...data })}\n\n`);
201
-
202
- ev('message_start', {
203
- message: { ...message, content: [], stop_reason: null, usage: { ...message.usage, output_tokens: 0 } },
204
- });
205
- message.content.forEach((block, index) => {
206
- if (block.type === 'text') {
207
- ev('content_block_start', { index, content_block: { type: 'text', text: '' } });
208
- ev('content_block_delta', { index, delta: { type: 'text_delta', text: block.text } });
209
- } else {
210
- ev('content_block_start', { index, content_block: { type: 'tool_use', id: block.id, name: block.name, input: {} } });
211
- ev('content_block_delta', { index, delta: { type: 'input_json_delta', partial_json: JSON.stringify(block.input ?? {}) } });
212
- }
213
- ev('content_block_stop', { index });
214
- });
215
- ev('message_delta', {
216
- delta: { stop_reason: message.stop_reason, stop_sequence: null },
217
- usage: { output_tokens: message.usage.output_tokens },
218
- });
219
- ev('message_stop', {});
220
- res.end();
221
- }
222
-
223
- /**
224
- * Translate an OpenAI SSE stream into an Anthropic one, INCREMENTALLY.
225
- *
226
- * The buffered path above exists because the gateway used to answer with a
227
- * finished JSON body. It streams for real now, and piping those frames straight
228
- * through gives an Anthropic client a 200 whose body it cannot parse — observed
229
- * as "API returned an empty or malformed response (HTTP 200)". The stopgap was
230
- * to ask the gateway not to stream and keep buffering, which is correct and
231
- * silent: Claude Code sends max_tokens=32000 against a 600-turn transcript, so a
232
- * turn is MINUTES of zero bytes and reads as a hang.
233
- *
234
- * So: same grammar, emitted as it arrives.
235
- *
236
- * BLOCK INDICES ARE NOT THE OPENAI ONES. Anthropic numbers content blocks
237
- * sequentially across the whole message, while OpenAI numbers tool_calls in
238
- * their own space starting at 0 — which collides with the text block. Tool
239
- * indices are therefore remapped on first sight and remembered.
240
- *
241
- * `onReceipt` takes the gateway's trailing `: x402 {...}` SSE comment, which is
242
- * swallowed here rather than forwarded: it is ours, and the Anthropic grammar
243
- * has no room for it.
244
- */
245
- export function streamOpenAIToAnthropic(res, upstream, requestedModel, onReceipt) {
246
- const headers = {
247
- 'content-type': 'text/event-stream; charset=utf-8',
248
- 'cache-control': 'no-cache, no-transform',
249
- 'x-accel-buffering': 'no',
250
- connection: 'keep-alive',
251
- };
252
- const settle = upstream?.headers?.get?.('x-payment-response');
253
- if (settle) headers['x-payment-response'] = settle;
254
- res.writeHead(200, headers);
255
- const ev = (type, data) => res.write(`event: ${type}\ndata: ${JSON.stringify({ type, ...data })}\n\n`);
256
-
257
- let started = false;
258
- let textIndex = -1; // Anthropic index of the text block, -1 until opened
259
- let nextIndex = 0; // next free Anthropic block index
260
- const toolIndex = new Map(); // OpenAI tool_calls[].index -> Anthropic index
261
- const open = new Set();
262
- let stopReason = 'end_turn';
263
- let usage = null;
264
- let msgId = null;
265
-
266
- const start = () => {
267
- if (started) return;
268
- started = true;
269
- ev('message_start', {
270
- message: {
271
- id: msgId ?? `msg_${Date.now()}`, type: 'message', role: 'assistant',
272
- model: requestedModel, content: [], stop_reason: null, stop_sequence: null,
273
- usage: { input_tokens: 0, output_tokens: 0 },
274
- },
275
- });
276
- };
277
-
278
- const onChunk = (d) => {
279
- if (d.id && !msgId) msgId = d.id;
280
- if (d.usage) usage = d.usage;
281
- const ch = (d.choices ?? [])[0];
282
- if (!ch) return;
283
- start();
284
- const delta = ch.delta ?? {};
285
- // A REASONING CHUNK IS NOT A SILENT CHUNK.
286
- //
287
- // Only `delta.content` produced output here, so a reasoning model emitted
288
- // NOTHING on the socket for the whole time it was thinking. fable reasons
289
- // before it answers — measured at 53 completion tokens to say one word — so
290
- // a real turn is minutes of zero bytes, and any hop in between is entitled
291
- // to drop a connection that has gone idle.
292
- //
293
- // OBSERVED: a fable turn rendering as "I'll ground this in your own context
294
- // first (holo), scout the monorepo layout, then f" and stopping mid-word.
295
- // Claude Code 2.1.232 PRESERVES partial responses on a mid-stream drop
296
- // instead of erroring, which is why this reads as "empty/truncated output"
297
- // rather than as the connection failure it is.
298
- //
299
- // `ping` is in Anthropic's own SSE grammar and carries no content, so a
300
- // client either ignores it or treats it as liveness — never as text.
301
- if (!(typeof delta.content === 'string' && delta.content.length)
302
- && !(delta.tool_calls ?? []).length
303
- && !(typeof delta.refusal === 'string' && delta.refusal.length)) {
304
- ev('ping', {});
305
- }
306
- // A REFUSAL IS A RESPONSE. SHOW IT.
307
- //
308
- // Upstream returns refusals out-of-band: `finish_reason: "content_filter"`,
309
- // `content: null`, and the actual sentence in `refusal`. We only ever read
310
- // `delta.content`, so the whole turn translated to a VALID, EMPTY stream —
311
- // message_start, message_stop, output_tokens 0 — and rendered as silence.
312
- //
313
- // OBSERVED: a security audit of the user's OWN exploited protocol returning
314
- // nothing at all, repeatedly, and reading as "fable is broken" when the
315
- // provider had in fact answered: "This request triggered restrictions on
316
- // violative cyber content and was blocked under Anthropic's Usage Policy."
317
- // Being told you were refused is actionable — you can rephrase, or route to
318
- // another model. Being shown an empty box is not.
319
- if (typeof delta.refusal === 'string' && delta.refusal.length) {
320
- if (textIndex < 0) {
321
- textIndex = nextIndex++;
322
- ev('content_block_start', { index: textIndex, content_block: { type: 'text', text: '' } });
323
- open.add(textIndex);
324
- }
325
- ev('content_block_delta', { index: textIndex, delta: { type: 'text_delta', text: delta.refusal } });
326
- }
327
- if (typeof delta.content === 'string' && delta.content.length) {
328
- if (textIndex < 0) {
329
- textIndex = nextIndex++;
330
- ev('content_block_start', { index: textIndex, content_block: { type: 'text', text: '' } });
331
- open.add(textIndex);
332
- }
333
- ev('content_block_delta', { index: textIndex, delta: { type: 'text_delta', text: delta.content } });
334
- }
335
- for (const t of delta.tool_calls ?? []) {
336
- const k = t.index ?? 0;
337
- if (!toolIndex.has(k)) {
338
- const idx = nextIndex++;
339
- toolIndex.set(k, idx);
340
- ev('content_block_start', {
341
- index: idx,
342
- content_block: { type: 'tool_use', id: t.id ?? `toolu_${idx}`, name: t.function?.name ?? '', input: {} },
343
- });
344
- open.add(idx);
345
- }
346
- const frag = t.function?.arguments;
347
- if (typeof frag === 'string' && frag.length) {
348
- ev('content_block_delta', {
349
- index: toolIndex.get(k),
350
- delta: { type: 'input_json_delta', partial_json: frag },
351
- });
352
- }
353
- }
354
- if (ch.finish_reason) stopReason = STOP_REASON[ch.finish_reason] ?? 'end_turn';
355
- };
356
-
357
- const finish = () => {
358
- start(); // a stream that produced nothing still owes the client a message
359
- for (const i of open) ev('content_block_stop', { index: i });
360
- ev('message_delta', {
361
- delta: { stop_reason: stopReason, stop_sequence: null },
362
- usage: { output_tokens: usage?.completion_tokens ?? 0 },
363
- });
364
- ev('message_stop', {});
365
- res.end();
366
- };
367
-
368
- return { onChunk, finish, receipt: onReceipt, usageOf: () => usage };
369
- }