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 +175 -0
- package/lib/proxy.js +33 -0
- package/package.json +1 -1
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.
|
|
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",
|