openzoo 0.7.2 → 0.9.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
@@ -4,10 +4,10 @@ const cmd = process.argv[2] || 'proxy';
4
4
  const HELP = `openzoo — local x402-paying proxy + MCP server for openzoo.fun
5
5
 
6
6
  usage:
7
- npx openzoo start the proxy on http://localhost:8402/v1
7
+ npx openzoo start the proxy: http://localhost:8402/v1 (keyless) PLUS a
8
+ public HTTPS url for cloud IDEs (key required, printed at start)
8
9
  npx openzoo mcp stdio MCP server (tools: zoo_ask, zoo_models, zoo_wallet)
9
- npx openzoo tunnel public HTTPS url for cloud IDEs that cannot reach localhost
10
- (installs cloudflared itself; mints a required api key)
10
+ npx openzoo tunnel public-url-only mode (everything key-gated, no keyless localhost)
11
11
  npx openzoo demo ~1M-token needle demo: direct refuses, the zoo answers
12
12
  (run it twice — the second run reuses the bound corpus and is near-free)
13
13
  npx openzoo contexts list corpora bound to the zoo (never re-uploaded)
@@ -35,13 +35,14 @@ env:
35
35
  OPENZOO_NO_CONTEXT_CACHE (0 — set 1 to always ship the full body)
36
36
  OPENZOO_ENABLE_RH (0 — let DEFAULT selection fall through to the Robinhood rail;
37
37
  OPENZOO_RAIL=robinhood forces it without this)
38
- OPENZOO_TUNNEL_MAX_USD (1.00 — tunnel session ceiling) OPENZOO_TUNNEL_TOKEN (pin the api key)`;
38
+ OPENZOO_TUNNEL_MAX_USD (1.00 — public-url session ceiling) OPENZOO_TUNNEL_TOKEN (pin the api key)
39
+ OPENZOO_NO_TUNNEL (0 — set 1 for localhost-only, no public url)`;
39
40
 
40
41
  async function main() {
41
42
  switch (cmd) {
42
43
  case 'proxy':
43
44
  case 'start':
44
- await (await import('../lib/proxy.js')).startProxy();
45
+ await (await import('../lib/proxy.js')).startProxy({ autoTunnel: true });
45
46
  break;
46
47
  case 'mcp':
47
48
  await (await import('../lib/mcp.js')).startMcp();
package/lib/models.js ADDED
@@ -0,0 +1,175 @@
1
+ import { config } from './config.js';
2
+
3
+ /**
4
+ * Model-id rewriting — "any harness, zero model setup".
5
+ *
6
+ * Cursor and friends send THEIR model ids ("gpt-5.6-sol", "gpt-4o",
7
+ * "claude-…") to whatever base URL is configured, and the zoo answers
8
+ * "model not available". Users will not hand-add custom models per harness,
9
+ * so the proxy maps unknown ids onto the zoo's live catalog instead:
10
+ *
11
+ * 1. an id the zoo serves passes through UNTOUCHED — this layer can never
12
+ * hijack an explicit, valid choice;
13
+ * 2. a family hint in the requested id (grok→x-ai/, gemini→google/, …)
14
+ * picks the plain (non-:free/:batch) model of that family;
15
+ * 3. anything else — gpt-*, claude-*, composer, o3 — falls through to
16
+ * OPENZOO_DEFAULT_MODEL, or a preference-ordered pick from the catalog.
17
+ *
18
+ * Every rewrite is logged with both ids. The catalog is fetched live and
19
+ * cached briefly, so new zoo models resolve without shipping this package.
20
+ */
21
+
22
+ const CATALOG_TTL_MS = 5 * 60 * 1000;
23
+ let cache = { at: 0, ids: null };
24
+
25
+ export async function zooModelIds() {
26
+ if (cache.ids && Date.now() - cache.at < CATALOG_TTL_MS) return cache.ids;
27
+ const r = await fetch(`${config.apiBase}/v1/models`);
28
+ if (!r.ok) throw new Error(`model catalog fetch failed: HTTP ${r.status}`);
29
+ const d = await r.json();
30
+ const ids = (d.data || []).map((m) => m.id).filter(Boolean);
31
+ if (ids.length) cache = { at: Date.now(), ids };
32
+ return ids;
33
+ }
34
+
35
+ /** Vendor fingerprints in harness model ids → zoo catalog prefixes. Order
36
+ * matters only for overlapping hints; first match wins. */
37
+ const FAMILIES = [
38
+ [/^gpt|^chatgpt|^o[134]\b|^o[134]-|openai/i, 'openai/'],
39
+ [/claude|anthropic/i, 'anthropic/'],
40
+ [/gemini|google/i, 'google/'],
41
+ [/grok|x-?ai/i, 'x-ai/'],
42
+ [/deepseek/i, 'deepseek/'],
43
+ [/qwen/i, 'qwen/'],
44
+ [/mistral|mixtral|codestral/i, 'mistralai/'],
45
+ [/llama|meta\b/i, 'meta-llama/'],
46
+ [/glm|z-ai|zhipu/i, 'z-ai/'],
47
+ [/kimi|moonshot/i, 'moonshotai/'],
48
+ [/minimax/i, 'minimax/'],
49
+ [/command|cohere/i, 'cohere/'],
50
+ [/nova|amazon/i, 'amazon/'],
51
+ [/sonar|perplexity/i, 'perplexity/'],
52
+ [/nemotron|nvidia/i, 'nvidia/'],
53
+ [/seed|doubao|bytedance/i, 'bytedance-seed/'],
54
+ [/solar|upstage/i, 'upstage/'],
55
+ [/liquid|lfm/i, 'liquid/'],
56
+ [/sakana/i, 'sakana/'],
57
+ ];
58
+
59
+ /**
60
+ * Capability-tier fingerprints. The rewrite must land on a LIKE model — a
61
+ * harness asking for a flagship gets the zoo's flagship, "mini"/"flash" gets
62
+ * a light model, a reasoning id gets the heaviest thing available — never a
63
+ * one-size-fits-all default.
64
+ */
65
+ const LIGHT_RE = /mini|nano|flash|lite|small|tiny|haiku|lightning|turbo/i;
66
+ const HEAVY_RE = /pro\b|pro-|max\b|opus|ultra|large|\bsol\b/i;
67
+ const REASON_RE = /^o[134]\b|^o[134]-|reason|think|r1\b|deepthink/i;
68
+ const CODE_RE = /code|coder|codex|composer|copilot/i;
69
+
70
+ /** "2.6b" → light, "70b"/"2.4t" → heavy; a param count in the id outranks words. */
71
+ function paramTier(id) {
72
+ const m = /(\d+(?:\.\d+)?)([bt])\b/i.exec(id);
73
+ if (!m) return null;
74
+ const n = Number(m[1]) * (m[2].toLowerCase() === 't' ? 1000 : 1);
75
+ return n >= 60 ? 'heavy' : n < 15 ? 'light' : 'mid';
76
+ }
77
+
78
+ function tierOf(id) {
79
+ if (REASON_RE.test(id)) return 'reason';
80
+ return paramTier(id) || (LIGHT_RE.test(id) ? 'light' : HEAVY_RE.test(id) ? 'heavy' : 'mid');
81
+ }
82
+
83
+ const GENERIC_TOKENS = new Set(['chat', 'model', 'latest', 'preview', 'instruct', 'v1', 'v2', 'v3', 'v4']);
84
+ const tokensOf = (id) => id.toLowerCase().split(/[^a-z0-9.]+/).filter((t) => t && !GENERIC_TOKENS.has(t));
85
+
86
+ /**
87
+ * Map a requested model id onto the catalog by similarity. Returns null when
88
+ * the id is already servable (no rewrite), otherwise the closest zoo id.
89
+ * OPENZOO_DEFAULT_MODEL is an explicit user override, not a fallback tier.
90
+ */
91
+ export function resolveModel(requested, ids) {
92
+ if (!requested || !ids?.length || ids.includes(requested)) return null;
93
+ const env = process.env.OPENZOO_DEFAULT_MODEL;
94
+ if (env && ids.includes(env)) return env;
95
+
96
+ const reqTier = tierOf(requested);
97
+ const reqCode = CODE_RE.test(requested);
98
+ const reqToks = new Set(tokensOf(requested));
99
+
100
+ let best = null;
101
+ let bestScore = -Infinity;
102
+ for (const id of ids) {
103
+ let score = 0;
104
+ // Same vendor family is the strongest signal there is.
105
+ for (const [re, prefix] of FAMILIES) {
106
+ if (re.test(requested) && id.startsWith(prefix)) { score += 100; break; }
107
+ }
108
+ // Tier: exact match strong; reasoning degrades to heavy (a reasoner's
109
+ // nearest neighbour is a flagship, never a mini); mid borders both.
110
+ const t = tierOf(id);
111
+ if (t === reqTier) score += 40;
112
+ else if (reqTier === 'reason' && t === 'heavy') score += 30;
113
+ else if ((reqTier === 'mid') !== (t === 'mid') && t !== 'light' && reqTier !== 'light') score += 15;
114
+ else if ((reqTier === 'light' && t === 'mid') || (reqTier === 'mid' && t === 'light')) score += 15;
115
+ // Specialisation: code asks want code models; nothing else does.
116
+ if (CODE_RE.test(id)) score += reqCode ? 25 : -8;
117
+ // Shared name tokens ("grok", "4.6", "sonnet") pull toward the namesake.
118
+ for (const tok of tokensOf(id)) if (reqToks.has(tok)) score += 10;
119
+ // Full-strength beats :free/:batch variants at equal similarity.
120
+ if (!id.includes(':')) score += 5;
121
+ if (score > bestScore) { bestScore = score; best = id; }
122
+ }
123
+ return best;
124
+ }
125
+
126
+ /**
127
+ * Ids harnesses ship as DEFAULTS (Cursor, Continue, Aider, Codex CLI, Cline,
128
+ * OpenClaw, LangChain templates…). Merged into GET /v1/models so a harness
129
+ * that validates its configured model against the list passes validation —
130
+ * the POST is then rewritten by resolveModel. Every one of these resolves.
131
+ */
132
+ export const ALIAS_IDS = [
133
+ 'gpt-4o', 'gpt-4o-mini', 'gpt-4.1', 'gpt-4.1-mini', 'gpt-4-turbo', 'gpt-3.5-turbo',
134
+ 'gpt-5', 'gpt-5-mini', 'chatgpt-4o-latest', 'o1', 'o3', 'o3-mini', 'o4-mini',
135
+ 'claude-3-5-sonnet-latest', 'claude-sonnet-4-0', 'claude-opus-4-1',
136
+ 'gemini-2.5-pro', 'gemini-2.5-flash', 'grok-4', 'grok-3',
137
+ 'deepseek-chat', 'deepseek-reasoner', 'qwen-max', 'llama-3.3-70b',
138
+ ];
139
+
140
+ /** Merge alias rows into a /v1/models payload without duplicating real ids. */
141
+ export function augmentModelList(payload) {
142
+ const data = Array.isArray(payload?.data) ? payload.data : [];
143
+ const have = new Set(data.map((m) => m.id));
144
+ const aliases = ALIAS_IDS.filter((id) => !have.has(id))
145
+ .map((id) => ({ id, object: 'model', owned_by: 'openzoo-alias' }));
146
+ return { ...payload, object: payload?.object || 'list', data: [...data, ...aliases] };
147
+ }
148
+
149
+ /**
150
+ * Which request paths carry a rewritable model field. POST-only; embeddings /
151
+ * audio / image / moderation models are DIFFERENT model families — rewriting
152
+ * a chat model into those would corrupt the call, so they pass untouched.
153
+ */
154
+ export function rewritablePath(method, url) {
155
+ if (method !== 'POST') return false;
156
+ const p = (url || '').split('?')[0];
157
+ return !/embed|audio|image|moderation/.test(p);
158
+ }
159
+
160
+ /**
161
+ * Rewrite the model field of any request body that has one.
162
+ * Returns null (send as-is) or { body, from, to }. Any failure — bad JSON,
163
+ * unreachable catalog — returns null: this layer must never break a call
164
+ * that would have worked without it.
165
+ */
166
+ export async function maybeRewriteModel(bodyBuf) {
167
+ let body;
168
+ try { body = JSON.parse(bodyBuf.toString('utf8')); } catch { return null; }
169
+ if (typeof body?.model !== 'string') return null;
170
+ let ids;
171
+ try { ids = await zooModelIds(); } catch { return null; }
172
+ const to = resolveModel(body.model, ids);
173
+ if (!to) return null;
174
+ return { body: Buffer.from(JSON.stringify({ ...body, model: to })), from: body.model, to };
175
+ }
package/lib/proxy.js CHANGED
@@ -6,6 +6,7 @@ import {
6
6
  import { PayClient, QuoteTooHighError, UnderfundedError } from './pay.js';
7
7
  import { tokenBalance } from './x402.js';
8
8
  import { bindCorpus, contextCacheDisabled, BIND_MIN_CHARS } from './hrr.js';
9
+ import { maybeRewriteModel, rewritablePath, augmentModelList, ALIAS_IDS } from './models.js';
9
10
  import { forgetContext } from './contexts.js';
10
11
 
11
12
  const HOP_BY_HOP = new Set([
@@ -110,13 +111,23 @@ async function maybeCacheCorpus(req, bodyBuf, log) {
110
111
  * becomes the only thing between a stranger and your wallet. Both are off by
111
112
  * default, so localhost behaviour is unchanged.
112
113
  */
113
- export async function startProxy({ silent = false, requireToken = null, sessionMaxUsd = null } = {}) {
114
+ export async function startProxy({ silent = false, requireToken = null, sessionMaxUsd = null, autoTunnel = false } = {}) {
114
115
  const client = new PayClient();
115
116
  const log = silent ? () => {} : (...a) => console.log(...a);
116
117
  let sessionSpent = 0;
118
+ let tunnelSpent = 0;
119
+ // Set once cloudflared is up (see below). Gating keys off the REQUEST's
120
+ // origin, not off whether the URL exists yet, so there is no startup window
121
+ // where public traffic slips through ungated.
122
+ let tunnelGate = null;
117
123
 
118
124
  const server = http.createServer(async (req, res) => {
119
125
  const url = `${config.apiBase}${req.url}`;
126
+ // Requests that arrived over the public quick-tunnel URL carry cloudflared's
127
+ // headers; nothing dialing 127.0.0.1 directly does. That distinction is what
128
+ // lets localhost stay keyless while the SAME port is safely public.
129
+ const viaTunnel = !requireToken && tunnelGate
130
+ && Boolean(req.headers['cf-connecting-ip'] || req.headers['cf-ray']);
120
131
  // Auth first: refuse before reading a body, forwarding, quoting or paying.
121
132
  if (requireToken) {
122
133
  const got = (req.headers.authorization || '').replace(/^Bearer\s+/i, '').trim();
@@ -130,6 +141,18 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
130
141
  return;
131
142
  }
132
143
  }
144
+ if (viaTunnel) {
145
+ const got = (req.headers.authorization || '').replace(/^Bearer\s+/i, '').trim();
146
+ if (got !== tunnelGate.token) {
147
+ log(`public url: 401 ${req.method} ${req.url}`);
148
+ jsonErr(res, 401, 'unauthorized: this openzoo public URL requires the api key printed at startup');
149
+ return;
150
+ }
151
+ if (tunnelSpent >= tunnelGate.sessionMaxUsd) {
152
+ jsonErr(res, 402, `openzoo public-URL session cap reached ($${tunnelGate.sessionMaxUsd}) — restart the proxy or raise OPENZOO_TUNNEL_MAX_USD`);
153
+ return;
154
+ }
155
+ }
133
156
  let bodyBuf;
134
157
  try {
135
158
  bodyBuf = await readBody(req);
@@ -137,9 +160,41 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
137
160
  jsonErr(res, 400, 'bad request body');
138
161
  return;
139
162
  }
163
+ // Harness model ids ("gpt-5.6-sol", "claude-…") are rewritten onto the
164
+ // NEAREST zoo model BEFORE anything else sees the body — any POST that
165
+ // carries a model field, not just chat/completions, so /completions,
166
+ // /responses and future shapes all work. Never silent.
167
+ if (rewritablePath(req.method, req.url)) {
168
+ const rw = await maybeRewriteModel(bodyBuf);
169
+ if (rw) {
170
+ log(`model "${rw.from}" is not on the zoo — nearest match ${rw.to} (OPENZOO_DEFAULT_MODEL overrides)`);
171
+ bodyBuf = rw.body;
172
+ }
173
+ }
140
174
  const init = { method: req.method, headers: upstreamHeaders(req) };
141
175
  if (req.method !== 'GET' && req.method !== 'HEAD') init.body = bodyBuf;
142
176
 
177
+ // Harnesses validate their configured model BEFORE ever POSTing — some
178
+ // list /v1/models, some probe /v1/models/<id>. Both must succeed for the
179
+ // ids we know how to rewrite, or the harness refuses upfront and the
180
+ // rewrite never gets its chance.
181
+ const path = (req.url || '').split('?')[0];
182
+ if (req.method === 'GET' && path === '/v1/models') {
183
+ try {
184
+ const { response } = await client.fetch(url, init);
185
+ const payload = await response.json();
186
+ res.writeHead(response.status, { 'content-type': 'application/json' });
187
+ res.end(JSON.stringify(response.ok ? augmentModelList(payload) : payload));
188
+ return;
189
+ } catch { /* fall through to the plain relay below */ }
190
+ }
191
+ const probe = req.method === 'GET' && /^\/v1\/models\/(.+)$/.exec(path);
192
+ if (probe && ALIAS_IDS.includes(decodeURIComponent(probe[1]))) {
193
+ res.writeHead(200, { 'content-type': 'application/json' });
194
+ res.end(JSON.stringify({ id: decodeURIComponent(probe[1]), object: 'model', owned_by: 'openzoo-alias' }));
195
+ return;
196
+ }
197
+
143
198
  try {
144
199
  let cached = null;
145
200
  try {
@@ -175,11 +230,17 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
175
230
  }
176
231
  const { response, paid, receipt } = result;
177
232
  if (paid && receipt) {
178
- if (receipt.ok && typeof receipt.billedUsd === 'number') sessionSpent += receipt.billedUsd;
233
+ if (receipt.ok && typeof receipt.billedUsd === 'number') {
234
+ sessionSpent += receipt.billedUsd;
235
+ // The public-URL ceiling meters only public-origin spend — your own
236
+ // local calls never eat into it.
237
+ if (viaTunnel) tunnelSpent += receipt.billedUsd;
238
+ }
179
239
  const line = receipt.ok ? receipt.line : `paid retry -> HTTP ${receipt.status}`;
180
- // In tunnel mode the running total is the thing you actually want to
181
- // watch, so it rides on every receipt.
182
- log(requireToken ? `${line} · session $${sessionSpent.toFixed(6)}` : line);
240
+ // Wherever a running total is the thing to watch, it rides the receipt.
241
+ if (requireToken) log(`${line} · session $${sessionSpent.toFixed(6)}`);
242
+ else if (viaTunnel) log(`${line} · public-url session $${tunnelSpent.toFixed(6)}`);
243
+ else log(line);
183
244
  }
184
245
  await relay(res, response);
185
246
  } catch (err) {
@@ -252,5 +313,35 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
252
313
  console.log(` base_url = http://localhost:${config.port}/v1`);
253
314
  console.log(' api_key = sk-openzoo (any value works; the zoo takes payment, not keys)');
254
315
  }
316
+
317
+ // AUTO-TUNNEL: `npx openzoo` must be end-to-end for cloud IDEs too — their
318
+ // servers cannot dial localhost, so the default command also publishes a
319
+ // quick-tunnel URL. It comes up in the background (never delays localhost),
320
+ // failure degrades to local-only, and public traffic is gated above by
321
+ // token + its own spend ceiling. OPENZOO_NO_TUNNEL=1 opts out.
322
+ if (autoTunnel && process.env.OPENZOO_NO_TUNNEL !== '1') {
323
+ (async () => {
324
+ try {
325
+ const { ensureCloudflared, startCloudflared, mintToken } = await import('./tunnel.js');
326
+ const token = mintToken();
327
+ const cap = Number(process.env.OPENZOO_TUNNEL_MAX_USD || 1);
328
+ const bin = await ensureCloudflared((m) => log(m));
329
+ const { url, proc } = await startCloudflared(bin, config.port, log);
330
+ tunnelGate = { token, sessionMaxUsd: cap };
331
+ const bye = () => { try { proc.kill('SIGTERM'); } catch { /* already gone */ } };
332
+ process.once('SIGINT', () => { bye(); process.exit(0); });
333
+ process.once('SIGTERM', () => { bye(); process.exit(0); });
334
+ process.once('exit', bye);
335
+ log('');
336
+ log('cloud IDE / remote harness? use the public URL (they cannot reach localhost):');
337
+ log(` base_url = ${url}/v1`);
338
+ log(` api_key = ${token}`);
339
+ log(` (key REQUIRED on the public URL — it spends this wallet; capped at $${cap.toFixed(2)}/session,`);
340
+ log(' OPENZOO_TUNNEL_MAX_USD to change, OPENZOO_NO_TUNNEL=1 for localhost-only)');
341
+ } catch (err) {
342
+ log(`public URL unavailable (${err.message}) — localhost still works; OPENZOO_NO_TUNNEL=1 hides this line`);
343
+ }
344
+ })();
345
+ }
255
346
  return { server, client, spent: () => sessionSpent };
256
347
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openzoo",
3
- "version": "0.7.2",
3
+ "version": "0.9.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",