openzoo 0.12.4 → 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 +17 -1
- package/lib/anthropic.js +172 -0
- package/lib/launch.js +43 -0
- package/lib/proxy.js +39 -1
- package/package.json +1 -1
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
|
|
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;
|
package/lib/anthropic.js
ADDED
|
@@ -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/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
|
-
|
|
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.
|
|
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",
|