openzoo 0.8.0 → 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/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([
@@ -159,9 +160,41 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
159
160
  jsonErr(res, 400, 'bad request body');
160
161
  return;
161
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
+ }
162
174
  const init = { method: req.method, headers: upstreamHeaders(req) };
163
175
  if (req.method !== 'GET' && req.method !== 'HEAD') init.body = bodyBuf;
164
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
+
165
198
  try {
166
199
  let cached = null;
167
200
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openzoo",
3
- "version": "0.8.0",
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",