openzoo 0.12.3 → 0.13.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
@@ -6,7 +6,10 @@ const HELP = `openzoo — local x402-paying proxy + MCP server for openzoo.fun
6
6
  usage:
7
7
  npx openzoo start the proxy: http://localhost:8402/v1 (keyless) PLUS a
8
8
  public HTTPS url for cloud IDEs (key required, printed at start)
9
- npx openzoo mcp stdio MCP server (tools: zoo_ask, zoo_models, zoo_wallet)
9
+ npx openzoo claude launch Claude Code through the zoo — every turn pays x402
10
+ (needs the proxy running; sets ANTHROPIC_BASE_URL for you)
11
+ npx openzoo launch <cmd> [args] same, for any Anthropic-shaped harness
12
+ npx openzoo mcp stdio MCP server (tools: zoo_ask, zoo_bind, zoo_models, zoo_wallet, zoo_contexts)
10
13
  npx openzoo tunnel public-url-only mode (everything key-gated, no keyless localhost)
11
14
  npx openzoo demo ~1M-token needle demo: direct refuses, the zoo answers
12
15
  (run it twice — the second run reuses the bound corpus and is near-free)
@@ -47,6 +50,19 @@ async function main() {
47
50
  case 'mcp':
48
51
  await (await import('../lib/mcp.js')).startMcp();
49
52
  break;
53
+ case 'claude':
54
+ case 'launch': {
55
+ // `npx openzoo claude [args]` runs Claude Code (or, with `launch <cmd>`,
56
+ // any Anthropic-shaped harness) through the local zoo — inference paid
57
+ // per turn over x402. The proxy must already be running.
58
+ const rest = process.argv.slice(3);
59
+ const [harness, hargs] = cmd === 'launch'
60
+ ? [rest[0], rest.slice(1)]
61
+ : ['claude', rest];
62
+ if (!harness) throw new Error('usage: openzoo launch <command> [args...]');
63
+ await (await import('../lib/launch.js')).launchHarness(harness, hargs);
64
+ break;
65
+ }
50
66
  case 'tunnel':
51
67
  await (await import('../lib/tunnel.js')).runTunnel();
52
68
  break;
@@ -0,0 +1,172 @@
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
+ if (Array.isArray(body.tools) && body.tools.length) {
91
+ out.tools = body.tools
92
+ .filter((t) => t?.name && t?.input_schema)
93
+ .map((t) => ({
94
+ type: 'function',
95
+ function: { name: t.name, description: t.description, parameters: t.input_schema },
96
+ }));
97
+ if (!out.tools.length) delete out.tools;
98
+ }
99
+ if (body.tool_choice?.type === 'auto') out.tool_choice = 'auto';
100
+ else if (body.tool_choice?.type === 'any') out.tool_choice = 'required';
101
+ else if (body.tool_choice?.type === 'tool') {
102
+ out.tool_choice = { type: 'function', function: { name: body.tool_choice.name } };
103
+ }
104
+ if (body.stop_sequences) { out.stop = body.stop_sequences; delete out.stop_sequences; }
105
+ return out;
106
+ }
107
+
108
+ const STOP_REASON = {
109
+ stop: 'end_turn', length: 'max_tokens', tool_calls: 'tool_use', content_filter: 'stop_sequence',
110
+ };
111
+
112
+ /** OpenAI completion -> Anthropic message. */
113
+ export function openAIToAnthropic(data, requestedModel) {
114
+ const choice = (data.choices ?? [])[0] ?? {};
115
+ const msg = choice.message ?? {};
116
+ const content = [];
117
+ if (msg.content) content.push({ type: 'text', text: msg.content });
118
+ for (const t of msg.tool_calls ?? []) {
119
+ let input = {};
120
+ try { input = JSON.parse(t.function?.arguments || '{}'); } catch { input = {}; }
121
+ content.push({ type: 'tool_use', id: t.id, name: t.function?.name, input });
122
+ }
123
+ return {
124
+ id: data.id ?? `msg_${Date.now()}`,
125
+ type: 'message',
126
+ role: 'assistant',
127
+ model: requestedModel ?? data.model,
128
+ content,
129
+ stop_reason: STOP_REASON[choice.finish_reason] ?? 'end_turn',
130
+ stop_sequence: null,
131
+ usage: {
132
+ input_tokens: data.usage?.prompt_tokens ?? 0,
133
+ output_tokens: data.usage?.completion_tokens ?? 0,
134
+ },
135
+ };
136
+ }
137
+
138
+ /**
139
+ * Emit a finished completion as an Anthropic SSE stream.
140
+ *
141
+ * The zoo settles payment before it answers, so there is nothing to stream
142
+ * until generation is done — same reason the OpenAI path re-emits chunks. A
143
+ * client that asked for a stream and got a JSON body treats the connection as
144
+ * dead and RETRIES, and every retry is another payment.
145
+ */
146
+ export function writeAnthropicSse(res, message, upstream) {
147
+ const headers = { 'content-type': 'text/event-stream; charset=utf-8', 'cache-control': 'no-cache' };
148
+ const settle = upstream?.headers?.get?.('x-payment-response');
149
+ if (settle) headers['x-payment-response'] = settle;
150
+ res.writeHead(200, headers);
151
+ const ev = (type, data) => res.write(`event: ${type}\ndata: ${JSON.stringify({ type, ...data })}\n\n`);
152
+
153
+ ev('message_start', {
154
+ message: { ...message, content: [], stop_reason: null, usage: { ...message.usage, output_tokens: 0 } },
155
+ });
156
+ message.content.forEach((block, index) => {
157
+ if (block.type === 'text') {
158
+ ev('content_block_start', { index, content_block: { type: 'text', text: '' } });
159
+ ev('content_block_delta', { index, delta: { type: 'text_delta', text: block.text } });
160
+ } else {
161
+ ev('content_block_start', { index, content_block: { type: 'tool_use', id: block.id, name: block.name, input: {} } });
162
+ ev('content_block_delta', { index, delta: { type: 'input_json_delta', partial_json: JSON.stringify(block.input ?? {}) } });
163
+ }
164
+ ev('content_block_stop', { index });
165
+ });
166
+ ev('message_delta', {
167
+ delta: { stop_reason: message.stop_reason, stop_sequence: null },
168
+ usage: { output_tokens: message.usage.output_tokens },
169
+ });
170
+ ev('message_stop', {});
171
+ res.end();
172
+ }
package/lib/bindpath.js CHANGED
@@ -25,9 +25,18 @@ import { config } from './config.js';
25
25
  import { corpusHash, rememberContext, lookupContext } from './contexts.js';
26
26
  import { withNamespace } from './namespace.js';
27
27
 
28
- /** Bodies over this go in their own part. Under the ~8MB transport ceiling
29
- * with room for JSON escaping, which can inflate text substantially. */
30
- export const MAX_PART_BYTES = Number(process.env.OPENZOO_BIND_PART_BYTES || 4_000_000);
28
+ /**
29
+ * Bodies over this go in their own part.
30
+ *
31
+ * Sized by TIME, not just by the transport ceiling. Each append re-indexes a
32
+ * larger context, so parts get progressively slower — MEASURED binding a 23MB
33
+ * Rust tree, 4MB parts ran 25s → 30s → 65s. A minutes-long bind is also a
34
+ * minutes-long window in which any gateway restart destroys all the work done
35
+ * so far (a deploy mid-bind cost exactly that, five good parts thrown away).
36
+ * Smaller parts cost more round trips, finish sooner, and lose less when
37
+ * something upstream cycles.
38
+ */
39
+ export const MAX_PART_BYTES = Number(process.env.OPENZOO_BIND_PART_BYTES || 2_000_000);
31
40
 
32
41
  /** Text-ish files worth binding. Anything else is skipped rather than
33
42
  * silently binding a binary as mojibake. */
package/lib/launch.js ADDED
@@ -0,0 +1,43 @@
1
+ /**
2
+ * `npx openzoo claude [args...]` — launch Claude Code (or any Anthropic-shaped
3
+ * harness) already pointed at the local zoo, so its inference is paid per turn
4
+ * over x402 instead of hitting Anthropic directly.
5
+ *
6
+ * This is the supported front door: ANTHROPIC_BASE_URL is an official env var
7
+ * Claude Code reads at startup. No DNS games, no TLS interception — the proxy
8
+ * serves POST /v1/messages (see lib/anthropic.js) and this just spawns the
9
+ * harness with the two env vars set. The proxy must already be running
10
+ * (`npx openzoo` in another terminal); we check first and say so if not.
11
+ */
12
+ import { spawn } from 'node:child_process';
13
+ import { config } from './config.js';
14
+
15
+ export async function launchHarness(cmd, args) {
16
+ const base = `http://localhost:${config.port}/v1`;
17
+ // Fail early with a clear message rather than letting the harness spew
18
+ // connection errors — the #1 support question would otherwise be "why won't
19
+ // claude connect" when the answer is "the proxy isn't up".
20
+ try {
21
+ const r = await fetch(`${base}/models`, { signal: AbortSignal.timeout(4000) });
22
+ if (!r.ok) throw new Error(String(r.status));
23
+ } catch {
24
+ console.error(`openzoo: no proxy reachable at ${base}`);
25
+ console.error('start it first in another terminal: npx openzoo');
26
+ process.exit(1);
27
+ }
28
+
29
+ const env = {
30
+ ...process.env,
31
+ ANTHROPIC_BASE_URL: base,
32
+ ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY || 'sk-openzoo',
33
+ // Claude Code sends model ids like "claude-opus-5"; the zoo serves those,
34
+ // and unknown ids are matched to the nearest served model regardless.
35
+ };
36
+ console.error(`openzoo: launching \`${cmd}\` on the zoo (ANTHROPIC_BASE_URL=${base}) — every turn pays x402`);
37
+ const child = spawn(cmd, args, { stdio: 'inherit', env });
38
+ child.on('exit', (code) => process.exit(code ?? 0));
39
+ child.on('error', (e) => {
40
+ console.error(`openzoo: could not launch \`${cmd}\`: ${e.message}`);
41
+ process.exit(1);
42
+ });
43
+ }
package/lib/proxy.js CHANGED
@@ -13,6 +13,7 @@ import { maybeRewriteModel, rewritablePath, augmentModelList, ALIAS_IDS } from '
13
13
  import { forgetContext } from './contexts.js';
14
14
  import { injectBrief } from './brief.js';
15
15
  import { withNamespace } from './namespace.js';
16
+ import { anthropicToOpenAI, openAIToAnthropic, writeAnthropicSse } from './anthropic.js';
16
17
 
17
18
  const HOP_BY_HOP = new Set([
18
19
  'host', 'connection', 'keep-alive', 'transfer-encoding', 'upgrade',
@@ -298,7 +299,7 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
298
299
  log(`path ${req.url} -> ${normalized} (base_url already ends in /v1)`);
299
300
  req.url = normalized;
300
301
  }
301
- const url = `${config.apiBase}${req.url}`;
302
+ let url = `${config.apiBase}${req.url}`;
302
303
  // Requests that arrived over the public quick-tunnel URL carry cloudflared's
303
304
  // headers; nothing dialing 127.0.0.1 directly does. That distinction is what
304
305
  // lets localhost stay keyless while the SAME port is safely public.
@@ -369,6 +370,31 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
369
370
  jsonErr(res, 400, 'bad request body');
370
371
  return;
371
372
  }
373
+
374
+ // ANTHROPIC MESSAGES SHAPE. A harness pointed here via ANTHROPIC_BASE_URL
375
+ // (Claude Code, the Anthropic SDKs) speaks POST /v1/messages, not chat
376
+ // completions — this is how such a harness routes its inference through
377
+ // x402 without any DNS or TLS trickery. Translate the body to OpenAI shape
378
+ // and rewrite the path so EVERYTHING downstream (model rewrite, brief,
379
+ // corpus cache, payment, replay, streaming) runs unchanged; translate the
380
+ // answer back on the way out. See lib/anthropic.js.
381
+ let anthropicMode = false;
382
+ let anthropicModel = null;
383
+ const rawPath = (req.url || '').split('?')[0];
384
+ if (req.method === 'POST' && (rawPath === '/v1/messages' || rawPath === '/messages')) {
385
+ try {
386
+ const inbound = JSON.parse(bodyBuf.toString('utf8'));
387
+ anthropicModel = inbound.model;
388
+ bodyBuf = Buffer.from(JSON.stringify(anthropicToOpenAI(inbound)));
389
+ anthropicMode = true;
390
+ req.url = '/v1/chat/completions';
391
+ url = `${config.apiBase}${req.url}`;
392
+ } catch {
393
+ jsonErr(res, 400, 'invalid anthropic messages body');
394
+ return;
395
+ }
396
+ }
397
+
372
398
  // Harness model ids ("gpt-5.6-sol", "claude-…") are rewritten onto the
373
399
  // NEAREST zoo model BEFORE anything else sees the body — any POST that
374
400
  // carries a model field, not just chat/completions, so /completions,
@@ -497,6 +523,18 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
497
523
  try { data = await response.clone().json(); } catch { /* not JSON after all */ }
498
524
  if (data?.object === 'chat.completion') {
499
525
  if (rKey) replayPut(rKey, data, response.headers.get('x-payment-response'));
526
+ // Anthropic-shaped caller gets an Anthropic-shaped answer, streamed
527
+ // or not, so Claude Code and the SDKs parse it natively.
528
+ if (anthropicMode) {
529
+ const msg = openAIToAnthropic(data, anthropicModel);
530
+ if (wantsStream) { writeAnthropicSse(res, msg, response); return; }
531
+ const h = { 'content-type': 'application/json' };
532
+ const settleHdr = response.headers.get('x-payment-response');
533
+ if (settleHdr) h['x-payment-response'] = settleHdr;
534
+ res.writeHead(200, h);
535
+ res.end(JSON.stringify(msg));
536
+ return;
537
+ }
500
538
  if (wantsStream) { serveAsSse(res, data, response); return; }
501
539
  const h = { 'content-type': 'application/json' };
502
540
  const settleHdr = response.headers.get('x-payment-response');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openzoo",
3
- "version": "0.12.3",
3
+ "version": "0.13.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",