openzoo 0.51.4 → 0.51.6
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/README.md +12 -1
- package/bin/openzoo.js +21 -46
- package/lib/chatgpt-install.js +106 -0
- package/lib/chatgpt.js +108 -90
- package/lib/config.js +34 -0
- package/lib/modelcommand.js +443 -0
- package/lib/pay.js +60 -4
- package/lib/proxy.js +39 -0
- package/package.json +4 -7
- package/transmute/README.md +0 -385
- package/transmute/bin/openzoo-transmute.js +0 -7
- package/transmute/docs/OPENZOO_CLI_PATCH.md +0 -139
- package/transmute/docs/VERCEL_TO_SOLANA.md +0 -381
- package/transmute/lib/build.js +0 -381
- package/transmute/lib/cli.js +0 -293
- package/transmute/lib/compile/bytecode.js +0 -591
- package/transmute/lib/compile/hostids.js +0 -11
- package/transmute/lib/compile/index.js +0 -159
- package/transmute/lib/compile/ir.js +0 -1634
- package/transmute/lib/compile/parse.js +0 -753
- package/transmute/lib/compile/rust.js +0 -781
- package/transmute/lib/deploy.js +0 -357
- package/transmute/lib/eligibility.js +0 -211
- package/transmute/lib/gateway.js +0 -710
- package/transmute/lib/hub.js +0 -290
- package/transmute/lib/solana.js +0 -387
- package/transmute/lib/vercel.js +0 -296
- package/transmute/lib/wallet.js +0 -41
- package/transmute/lib/wire.js +0 -208
- package/transmute/prebuilt/zoo_vm.v0.so +0 -0
- package/transmute/runtime/zoo-host/Cargo.lock +0 -588
- package/transmute/runtime/zoo-host/Cargo.toml +0 -30
- package/transmute/runtime/zoo-host/examples/vmrun.rs +0 -43
- package/transmute/runtime/zoo-host/src/assets.rs +0 -214
- package/transmute/runtime/zoo-host/src/ctx.rs +0 -350
- package/transmute/runtime/zoo-host/src/fmt.rs +0 -188
- package/transmute/runtime/zoo-host/src/helpers.rs +0 -245
- package/transmute/runtime/zoo-host/src/json.rs +0 -263
- package/transmute/runtime/zoo-host/src/kv.rs +0 -206
- package/transmute/runtime/zoo-host/src/lib.rs +0 -137
- package/transmute/runtime/zoo-host/src/site.rs +0 -94
- package/transmute/runtime/zoo-host/src/val.rs +0 -1050
- package/transmute/runtime/zoo-host/src/vm.rs +0 -902
- package/transmute/runtime/zoo-host/src/wire.rs +0 -239
- package/transmute/runtime/zoo-vm/Cargo.lock +0 -596
- package/transmute/runtime/zoo-vm/Cargo.toml +0 -23
- package/transmute/runtime/zoo-vm/src/lib.rs +0 -92
- package/transmute/site/.env.production +0 -2
- package/transmute/site/api/echo.js +0 -10
- package/transmute/site/api/hello.js +0 -12
- package/transmute/site/api/hits.js +0 -13
- package/transmute/site/api/time.js +0 -5
- package/transmute/site/package.json +0 -6
- package/transmute/site/public/app.js +0 -16
- package/transmute/site/public/index.html +0 -96
|
@@ -0,0 +1,443 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, mkdirSync, statSync, unlinkSync } from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { config } from './config.js';
|
|
5
|
+
import { quoteableRows } from './models.js';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* `/model <name>` AS A CHAT COMMAND.
|
|
9
|
+
*
|
|
10
|
+
* THE FAILURE THIS EXISTS FOR. A user running ChatGPT desktop (Codex side,
|
|
11
|
+
* pointed at this proxy) typed `/model fable-5` into the chat box expecting the
|
|
12
|
+
* app to switch models. The app has NO such command — it forwarded the line to
|
|
13
|
+
* the model as an ordinary question. The model then spent six minutes and real
|
|
14
|
+
* money shelling around the gateway trying to work out what the user meant,
|
|
15
|
+
* and the model never changed. Codex/ChatGPT gives no model picker for a
|
|
16
|
+
* custom provider, so without this the only ways to switch are editing
|
|
17
|
+
* ~/.codex/config.toml and restarting the app.
|
|
18
|
+
*
|
|
19
|
+
* So the PROXY answers the command: it never reaches the wire, nothing is
|
|
20
|
+
* paid, and the override is remembered on disk so it survives a proxy restart.
|
|
21
|
+
* Everything here is a pure function except the four file helpers, so the
|
|
22
|
+
* tests can drive it without a socket.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Normalised form for id comparison. MUST mirror the gateway's own matcher
|
|
27
|
+
* (x402-tokens src/modelmatch.ts) — a name the proxy accepts and the gateway
|
|
28
|
+
* then rejects is worse than no command at all, because the user has already
|
|
29
|
+
* been told "switched".
|
|
30
|
+
*
|
|
31
|
+
* Drop the vendor prefix, lowercase, and treat `.`/`_`/space as `-`, because
|
|
32
|
+
* the same model is spelled `claude-fable-5.1`, `claude_fable_5_1` and
|
|
33
|
+
* `anthropic/claude-fable-5-1` depending on who is printing it.
|
|
34
|
+
*/
|
|
35
|
+
export function normaliseModelId(s) {
|
|
36
|
+
const raw = String(s ?? '');
|
|
37
|
+
const bare = raw.slice(raw.lastIndexOf('/') + 1);
|
|
38
|
+
return bare
|
|
39
|
+
.toLowerCase()
|
|
40
|
+
.replace(/[._\s]+/g, '-')
|
|
41
|
+
.replace(/-+/g, '-')
|
|
42
|
+
.replace(/^-|-$/g, '');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Shortest normalised id first, then lexical on the REAL id — a total order,
|
|
46
|
+
* so the same typed name always lands on the same model. `claude-fable-5` and
|
|
47
|
+
* `venice/claude-fable-5` normalise identically; without the second key the
|
|
48
|
+
* winner would depend on catalog order, which changes hourly. */
|
|
49
|
+
function bestOf(cands) {
|
|
50
|
+
return cands.sort((a, b) => (
|
|
51
|
+
a.norm.length - b.norm.length || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)
|
|
52
|
+
))[0]?.id ?? null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Resolve a typed name against the gateway catalog. Exact normalised match,
|
|
57
|
+
* else a candidate ENDING in `-<want>` (so `fable-5` finds `claude-fable-5`),
|
|
58
|
+
* else a candidate merely containing it. Returns null when nothing fits.
|
|
59
|
+
*
|
|
60
|
+
* Two-character inputs are refused outright: `/model o3` style stubs matched
|
|
61
|
+
* dozens of unrelated ids by substring and the shortest-id tiebreak then picked
|
|
62
|
+
* an arbitrary one. A wrong model chosen silently is the failure mode this
|
|
63
|
+
* whole file is trying to end.
|
|
64
|
+
*/
|
|
65
|
+
export function matchCatalogId(want, ids) {
|
|
66
|
+
const w = normaliseModelId(want);
|
|
67
|
+
if (w.length < 3) return null;
|
|
68
|
+
const cands = (Array.isArray(ids) ? ids : [])
|
|
69
|
+
.filter((id) => typeof id === 'string' && id)
|
|
70
|
+
.map((id) => ({ id, norm: normaliseModelId(id) }));
|
|
71
|
+
const exact = cands.filter((c) => c.norm === w);
|
|
72
|
+
if (exact.length) return bestOf(exact);
|
|
73
|
+
const suffix = cands.filter((c) => c.norm.endsWith(`-${w}`));
|
|
74
|
+
if (suffix.length) return bestOf(suffix);
|
|
75
|
+
const contains = cands.filter((c) => c.norm.includes(w));
|
|
76
|
+
if (contains.length) return bestOf(contains);
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Up to `n` ids to suggest after a miss. matchCatalogId already tried plain
|
|
82
|
+
* substring, so this deliberately goes LOOSER — every dash-token of what was
|
|
83
|
+
* typed, then shrinking prefixes — or a typo like `fabel-5` would print
|
|
84
|
+
* "no model matches" with no way forward.
|
|
85
|
+
*/
|
|
86
|
+
export function closestIds(want, ids, n = 3) {
|
|
87
|
+
const w = normaliseModelId(want);
|
|
88
|
+
const cands = (Array.isArray(ids) ? ids : [])
|
|
89
|
+
.filter((id) => typeof id === 'string' && id)
|
|
90
|
+
.map((id) => ({ id, norm: normaliseModelId(id) }));
|
|
91
|
+
const probes = [];
|
|
92
|
+
for (const tok of w.split('-')) if (tok.length >= 3) probes.push(tok);
|
|
93
|
+
for (let len = w.length - 1; len >= 3; len -= 1) probes.push(w.slice(0, len));
|
|
94
|
+
const out = [];
|
|
95
|
+
const seen = new Set();
|
|
96
|
+
for (const probe of probes) {
|
|
97
|
+
const hits = cands.filter((c) => c.norm.includes(probe))
|
|
98
|
+
.sort((a, b) => a.norm.length - b.norm.length || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
|
|
99
|
+
for (const h of hits) {
|
|
100
|
+
if (seen.has(h.id)) continue;
|
|
101
|
+
seen.add(h.id);
|
|
102
|
+
out.push(h.id);
|
|
103
|
+
if (out.length >= n) return out;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return out;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Names people actually type, resolved against the LIVE catalog rather than
|
|
110
|
+
* hardcoded — a hardcoded list goes stale the week the zoo re-quotes. */
|
|
111
|
+
const POPULAR_HINTS = ['claude-fable-5', 'claude-opus-5', 'gpt-5', 'grok-4', 'gemini-3-pro'];
|
|
112
|
+
|
|
113
|
+
export function popularIds(ids, n = 5) {
|
|
114
|
+
const out = [];
|
|
115
|
+
const push = (id) => { if (id && !out.includes(id)) out.push(id); };
|
|
116
|
+
for (const hint of POPULAR_HINTS) push(matchCatalogId(hint, ids));
|
|
117
|
+
for (const id of Array.isArray(ids) ? ids : []) { if (out.length >= n) break; push(id); }
|
|
118
|
+
return out.slice(0, n);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// ---------------------------------------------------------------------------
|
|
122
|
+
// Detection: what did the user's LAST turn actually say?
|
|
123
|
+
// ---------------------------------------------------------------------------
|
|
124
|
+
|
|
125
|
+
/** Text of one message/item `content`, which is a bare string on the Chat
|
|
126
|
+
* Completions side and an array of typed parts on the Responses side
|
|
127
|
+
* (Codex sends `input_text`, browsers send `text`). */
|
|
128
|
+
function partsText(content) {
|
|
129
|
+
if (typeof content === 'string') return content;
|
|
130
|
+
if (!Array.isArray(content)) return '';
|
|
131
|
+
return content
|
|
132
|
+
.filter((p) => p && typeof p.text === 'string' && (!p.type || p.type === 'text' || p.type === 'input_text' || p.type === 'output_text'))
|
|
133
|
+
.map((p) => p.text)
|
|
134
|
+
.join('\n');
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* The last USER turn of a request body, for both shapes we are ever sent:
|
|
139
|
+
* `messages[]` (chat completions + Anthropic messages) and `input`
|
|
140
|
+
* (Responses API — what ChatGPT/Codex posts). Anything else returns ''.
|
|
141
|
+
*
|
|
142
|
+
* Last-user-turn only, never a scan of the whole conversation: a transcript
|
|
143
|
+
* that happens to quote "/model fable-5" from three turns ago must not
|
|
144
|
+
* re-trigger the switch on an unrelated question.
|
|
145
|
+
*/
|
|
146
|
+
export function lastUserText(body) {
|
|
147
|
+
if (!body || typeof body !== 'object') return '';
|
|
148
|
+
if (Array.isArray(body.messages)) {
|
|
149
|
+
for (let i = body.messages.length - 1; i >= 0; i -= 1) {
|
|
150
|
+
const m = body.messages[i];
|
|
151
|
+
if (m && m.role === 'user') return partsText(m.content);
|
|
152
|
+
}
|
|
153
|
+
return '';
|
|
154
|
+
}
|
|
155
|
+
if (typeof body.input === 'string') return body.input;
|
|
156
|
+
if (Array.isArray(body.input)) {
|
|
157
|
+
for (let i = body.input.length - 1; i >= 0; i -= 1) {
|
|
158
|
+
const it = body.input[i];
|
|
159
|
+
if (it && it.role === 'user') return partsText(it.content);
|
|
160
|
+
}
|
|
161
|
+
return '';
|
|
162
|
+
}
|
|
163
|
+
return '';
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* `{ arg }` when the last user turn IS the command, else null. Deliberately
|
|
168
|
+
* anchored: a message that merely mentions /model, or asks a question about
|
|
169
|
+
* it, is a real question and must be paid for and answered by a model.
|
|
170
|
+
*/
|
|
171
|
+
export function detectModelCommand(body) {
|
|
172
|
+
const text = String(lastUserText(body) || '').trim();
|
|
173
|
+
if (!text) return null;
|
|
174
|
+
const m = /^\/model(?:\s+(.+))?$/i.exec(text);
|
|
175
|
+
if (!m) return null;
|
|
176
|
+
return { arg: (m[1] || '').trim() };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// ---------------------------------------------------------------------------
|
|
180
|
+
// Persistence — one line in ~/.openzoo/model
|
|
181
|
+
// ---------------------------------------------------------------------------
|
|
182
|
+
|
|
183
|
+
/** Same directory as the wallet and proxy.log; there is no OPENZOO_HOME
|
|
184
|
+
* convention in this codebase, so homedir() it is (which honours $HOME, and
|
|
185
|
+
* that is how the tests get a throwaway one). */
|
|
186
|
+
export function modelFilePath(home = os.homedir()) {
|
|
187
|
+
return path.join(home, '.openzoo', 'model');
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Cached by (mtime,size) rather than read-once: `openzoo model X` in a second
|
|
191
|
+
// terminal writes this file while the proxy is running, and a plain in-memory
|
|
192
|
+
// cache would keep serving the old id until restart — the exact "no restart"
|
|
193
|
+
// promise the command makes.
|
|
194
|
+
let memo = { file: null, stamp: null, id: null };
|
|
195
|
+
|
|
196
|
+
export function readOverride(file = modelFilePath()) {
|
|
197
|
+
let stamp = 'missing';
|
|
198
|
+
try { const st = statSync(file); stamp = `${st.mtimeMs}:${st.size}`; } catch { /* not set */ }
|
|
199
|
+
if (memo.file === file && memo.stamp === stamp) return memo.id;
|
|
200
|
+
let id = null;
|
|
201
|
+
if (stamp !== 'missing') {
|
|
202
|
+
try { id = readFileSync(file, 'utf8').split('\n')[0].trim() || null; } catch { id = null; }
|
|
203
|
+
}
|
|
204
|
+
memo = { file, stamp, id };
|
|
205
|
+
return id;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export function writeOverride(id, file = modelFilePath()) {
|
|
209
|
+
mkdirSync(path.dirname(file), { recursive: true });
|
|
210
|
+
writeFileSync(file, `${id}\n`);
|
|
211
|
+
memo = { file: null, stamp: null, id: null };
|
|
212
|
+
return id;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export function clearOverride(file = modelFilePath()) {
|
|
216
|
+
try { unlinkSync(file); } catch { /* already gone */ }
|
|
217
|
+
memo = { file: null, stamp: null, id: null };
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const RESET_WORDS = new Set(['reset', 'default', 'off', 'none', 'clear']);
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Run the command and return what to say back. Pure apart from the one file
|
|
224
|
+
* write, so the reply text is testable.
|
|
225
|
+
*/
|
|
226
|
+
export function applyModelCommand(arg, ids, file = modelFilePath()) {
|
|
227
|
+
const current = readOverride(file);
|
|
228
|
+
const wanted = String(arg || '').trim();
|
|
229
|
+
if (!wanted) {
|
|
230
|
+
return { text: statusText(current, ids), model: current || 'openzoo', changed: false };
|
|
231
|
+
}
|
|
232
|
+
if (RESET_WORDS.has(wanted.toLowerCase())) {
|
|
233
|
+
clearOverride(file);
|
|
234
|
+
return {
|
|
235
|
+
text: current
|
|
236
|
+
? `Cleared the model override (was ${current}). Chats now use whatever the app sends.`
|
|
237
|
+
: 'No model override was set. Chats use whatever the app sends.',
|
|
238
|
+
model: 'openzoo',
|
|
239
|
+
changed: Boolean(current),
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
const hit = matchCatalogId(wanted, ids);
|
|
243
|
+
if (!hit) {
|
|
244
|
+
// A catalog we could not reach is NOT a bad name. Refusing here would make
|
|
245
|
+
// the command useless exactly when the gateway is flaky, so take the id as
|
|
246
|
+
// typed and say so — the next real request surfaces a wrong id anyway.
|
|
247
|
+
if (!Array.isArray(ids) || !ids.length) {
|
|
248
|
+
writeOverride(wanted, file);
|
|
249
|
+
return {
|
|
250
|
+
text: `Could not reach the model catalog, so I took "${wanted}" as typed. Every chat from here uses it until you send /model reset.`,
|
|
251
|
+
model: wanted,
|
|
252
|
+
changed: true,
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
const near = closestIds(wanted, ids);
|
|
256
|
+
return {
|
|
257
|
+
text: near.length
|
|
258
|
+
? `No model matches "${wanted}". Closest: ${near.join(', ')}.`
|
|
259
|
+
: `No model matches "${wanted}". Send /model on its own to see some ids.`,
|
|
260
|
+
model: current || 'openzoo',
|
|
261
|
+
changed: false,
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
writeOverride(hit, file);
|
|
265
|
+
return {
|
|
266
|
+
text: `Switched to ${hit}. Every chat from here uses it until you send /model reset.`,
|
|
267
|
+
model: hit,
|
|
268
|
+
changed: hit !== current,
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
export function statusText(current, ids) {
|
|
273
|
+
const lines = [current
|
|
274
|
+
? `Model override: ${current} — every chat uses it until you send /model reset.`
|
|
275
|
+
: 'Model override: none, using what the app sends.'];
|
|
276
|
+
const pop = popularIds(ids);
|
|
277
|
+
if (pop.length) {
|
|
278
|
+
lines.push('', 'Popular ids:');
|
|
279
|
+
for (const id of pop) lines.push(` ${id}`);
|
|
280
|
+
}
|
|
281
|
+
lines.push('', 'Usage: /model <name> · /model reset');
|
|
282
|
+
return lines.join('\n');
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// ---------------------------------------------------------------------------
|
|
286
|
+
// Reply synthesisers — one per wire shape the proxy serves
|
|
287
|
+
// ---------------------------------------------------------------------------
|
|
288
|
+
|
|
289
|
+
/** Which of the four paid shapes is this? The reply has to be in the SHAPE the
|
|
290
|
+
* client parses; a chat.completion sent to Codex's Responses parser is a hard
|
|
291
|
+
* client error, which reads to the user as "the proxy is broken". */
|
|
292
|
+
export function shapeForPath(rawPath) {
|
|
293
|
+
const p = String(rawPath || '');
|
|
294
|
+
if (/\/responses$/.test(p)) return 'responses';
|
|
295
|
+
if (/\/messages$/.test(p)) return 'anthropic';
|
|
296
|
+
return 'chat';
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
export function chatCompletionReply(text, model, now = Date.now()) {
|
|
300
|
+
return {
|
|
301
|
+
id: `chatcmpl-model-${now}`,
|
|
302
|
+
object: 'chat.completion',
|
|
303
|
+
created: Math.floor(now / 1000),
|
|
304
|
+
model,
|
|
305
|
+
choices: [{ index: 0, message: { role: 'assistant', content: text }, finish_reason: 'stop' }],
|
|
306
|
+
usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 },
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
export function responsesReply(text, model, now = Date.now()) {
|
|
311
|
+
return {
|
|
312
|
+
id: `resp_model_${now}`,
|
|
313
|
+
object: 'response',
|
|
314
|
+
created_at: Math.floor(now / 1000),
|
|
315
|
+
status: 'completed',
|
|
316
|
+
model,
|
|
317
|
+
output: [{
|
|
318
|
+
type: 'message',
|
|
319
|
+
id: `msg_model_${now}`,
|
|
320
|
+
status: 'completed',
|
|
321
|
+
role: 'assistant',
|
|
322
|
+
content: [{ type: 'output_text', text, annotations: [] }],
|
|
323
|
+
}],
|
|
324
|
+
usage: { input_tokens: 0, output_tokens: 0, total_tokens: 0 },
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* The Responses SSE script, in the order Codex's parser walks it. Codex
|
|
330
|
+
* ALWAYS streams, so this — not the JSON object above — is the path that
|
|
331
|
+
* actually runs in the app. `sequence_number` increments from 0 on every
|
|
332
|
+
* event: the parser tolerates gaps today, but an out-of-order or missing
|
|
333
|
+
* counter is the first thing that breaks when it stops tolerating them.
|
|
334
|
+
*/
|
|
335
|
+
export function responsesStreamEvents(text, model, now = Date.now()) {
|
|
336
|
+
const id = `resp_model_${now}`;
|
|
337
|
+
const itemId = `msg_model_${now}`;
|
|
338
|
+
const head = { id, object: 'response', created_at: Math.floor(now / 1000), model };
|
|
339
|
+
const item = (status, content) => ({ type: 'message', id: itemId, status, role: 'assistant', content });
|
|
340
|
+
const finished = item('completed', [{ type: 'output_text', text, annotations: [] }]);
|
|
341
|
+
let seq = 0;
|
|
342
|
+
const ev = (event, data) => ({ event, data: { ...data, sequence_number: seq++ } });
|
|
343
|
+
return [
|
|
344
|
+
ev('response.created', { type: 'response.created', response: { ...head, status: 'in_progress', output: [] } }),
|
|
345
|
+
ev('response.output_item.added', { type: 'response.output_item.added', output_index: 0, item: item('in_progress', []) }),
|
|
346
|
+
ev('response.content_part.added', { type: 'response.content_part.added', output_index: 0, item_id: itemId, content_index: 0, part: { type: 'output_text', text: '' } }),
|
|
347
|
+
ev('response.output_text.delta', { type: 'response.output_text.delta', output_index: 0, item_id: itemId, content_index: 0, delta: text }),
|
|
348
|
+
ev('response.output_text.done', { type: 'response.output_text.done', output_index: 0, item_id: itemId, content_index: 0, text }),
|
|
349
|
+
ev('response.content_part.done', { type: 'response.content_part.done', output_index: 0, item_id: itemId, content_index: 0, part: { type: 'output_text', text, annotations: [] } }),
|
|
350
|
+
ev('response.output_item.done', { type: 'response.output_item.done', output_index: 0, item: finished }),
|
|
351
|
+
ev('response.completed', { type: 'response.completed', response: { ...head, status: 'completed', output: [finished], usage: { input_tokens: 0, output_tokens: 0, total_tokens: 0 } } }),
|
|
352
|
+
];
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
export function anthropicReply(text, model, now = Date.now()) {
|
|
356
|
+
return {
|
|
357
|
+
id: `msg_model_${now}`,
|
|
358
|
+
type: 'message',
|
|
359
|
+
role: 'assistant',
|
|
360
|
+
model,
|
|
361
|
+
content: [{ type: 'text', text }],
|
|
362
|
+
stop_reason: 'end_turn',
|
|
363
|
+
stop_sequence: null,
|
|
364
|
+
usage: { input_tokens: 0, output_tokens: 0 },
|
|
365
|
+
};
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/** Anthropic's own event script. Same reason as the Responses one: a Messages
|
|
369
|
+
* client that asked for a stream and got a JSON body sits on a dead socket. */
|
|
370
|
+
export function anthropicStreamEvents(text, model, now = Date.now()) {
|
|
371
|
+
const message = { ...anthropicReply('', model, now), content: [], stop_reason: null };
|
|
372
|
+
return [
|
|
373
|
+
{ event: 'message_start', data: { type: 'message_start', message } },
|
|
374
|
+
{ event: 'content_block_start', data: { type: 'content_block_start', index: 0, content_block: { type: 'text', text: '' } } },
|
|
375
|
+
{ event: 'content_block_delta', data: { type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text } } },
|
|
376
|
+
{ event: 'content_block_stop', data: { type: 'content_block_stop', index: 0 } },
|
|
377
|
+
{ event: 'message_delta', data: { type: 'message_delta', delta: { stop_reason: 'end_turn', stop_sequence: null }, usage: { output_tokens: 0 } } },
|
|
378
|
+
{ event: 'message_stop', data: { type: 'message_stop' } },
|
|
379
|
+
];
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
export const SSE_HEADERS = {
|
|
383
|
+
'content-type': 'text/event-stream; charset=utf-8',
|
|
384
|
+
'cache-control': 'no-cache',
|
|
385
|
+
// Same reason as serveAsSse in proxy.js: a buffering hop in front of us
|
|
386
|
+
// (quick tunnel, nginx) merges frames and the client sees nothing until the
|
|
387
|
+
// end, which looks like a hang.
|
|
388
|
+
'x-accel-buffering': 'no',
|
|
389
|
+
connection: 'keep-alive',
|
|
390
|
+
};
|
|
391
|
+
|
|
392
|
+
export function writeSseEvents(res, events) {
|
|
393
|
+
res.writeHead(200, SSE_HEADERS);
|
|
394
|
+
for (const { event, data } of events) res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
|
|
395
|
+
res.end();
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* Answer the command on the socket, in whatever shape/streaming mode the
|
|
400
|
+
* client asked for. `serveAsSse` is injected rather than imported so this
|
|
401
|
+
* module stays free of proxy.js (which would be a cycle) and so the tests can
|
|
402
|
+
* pass a recording fake.
|
|
403
|
+
*/
|
|
404
|
+
export function serveModelCommand(res, { rawPath, stream, text, model, serveAsSse, now = Date.now() }) {
|
|
405
|
+
const shape = shapeForPath(rawPath);
|
|
406
|
+
if (shape === 'responses') {
|
|
407
|
+
if (stream) { writeSseEvents(res, responsesStreamEvents(text, model, now)); return shape; }
|
|
408
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
409
|
+
res.end(JSON.stringify(responsesReply(text, model, now)));
|
|
410
|
+
return shape;
|
|
411
|
+
}
|
|
412
|
+
if (shape === 'anthropic') {
|
|
413
|
+
if (stream) { writeSseEvents(res, anthropicStreamEvents(text, model, now)); return shape; }
|
|
414
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
415
|
+
res.end(JSON.stringify(anthropicReply(text, model, now)));
|
|
416
|
+
return shape;
|
|
417
|
+
}
|
|
418
|
+
const data = chatCompletionReply(text, model, now);
|
|
419
|
+
if (stream && typeof serveAsSse === 'function') { serveAsSse(res, data, null); return shape; }
|
|
420
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
421
|
+
res.end(JSON.stringify(data));
|
|
422
|
+
return shape;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/** Catalog ids for the CLI, which has no proxy cache to borrow. Soft-fails to
|
|
426
|
+
* [] so `openzoo model x` still sets an override with the gateway down. */
|
|
427
|
+
export async function catalogIds(apiBase = config.apiBase) {
|
|
428
|
+
try {
|
|
429
|
+
const r = await fetch(`${apiBase}/v1/models`, { signal: AbortSignal.timeout(8000) });
|
|
430
|
+
if (!r.ok) return [];
|
|
431
|
+
const payload = await r.json();
|
|
432
|
+
return quoteableRows(payload?.data).map((m) => m.id);
|
|
433
|
+
} catch { return []; }
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/** `openzoo model [id|reset]` — same override file the chat command writes, so
|
|
437
|
+
* a terminal and the chat box cannot disagree about which model is live. */
|
|
438
|
+
export async function cliModel(arg) {
|
|
439
|
+
const ids = await catalogIds();
|
|
440
|
+
const out = applyModelCommand(arg, ids);
|
|
441
|
+
console.log(out.text);
|
|
442
|
+
if (out.changed) console.log('(a running proxy picks this up on its next request — no restart)');
|
|
443
|
+
}
|
package/lib/pay.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Connection } from '@solana/web3.js';
|
|
2
|
-
import { config, fundingLine, evmRpcFor } from './config.js';
|
|
2
|
+
import { config, fundingLine, evmRpcFor, evmRpcForNetwork } from './config.js';
|
|
3
3
|
import { loadOrCreateWallet } from './wallet.js';
|
|
4
4
|
import {
|
|
5
5
|
parse402, orderAccepts, railOf, buildPaymentOnline, tokenBalance,
|
|
@@ -123,6 +123,30 @@ const RAIL_MEMO_MS = Number(process.env.OPENZOO_RAIL_MEMO_MS || 15_000);
|
|
|
123
123
|
const underfundedUntil = new Map(); // asset -> epoch ms after which to re-try it
|
|
124
124
|
let lastGoodAsset = null;
|
|
125
125
|
|
|
126
|
+
/**
|
|
127
|
+
* One line to stderr about which rail was chosen or skipped. Quiet by default
|
|
128
|
+
* would defeat the point — this is the answer to "why did my deposit not get
|
|
129
|
+
* used", and it is only printed when a rail was actually passed over, so a
|
|
130
|
+
* normal single-rail call stays silent. OPENZOO_QUIET=1 turns it off.
|
|
131
|
+
*/
|
|
132
|
+
function railNote(msg) {
|
|
133
|
+
if (process.env.OPENZOO_QUIET === '1') return;
|
|
134
|
+
try { process.stderr.write(`openzoo: ${msg}\n`); } catch { /* stderr closed */ }
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** "Base (eip155:8453)" — the name a human knows plus the id a log needs. */
|
|
138
|
+
function railLabel(accept) {
|
|
139
|
+
const n = chainName(accept?.network);
|
|
140
|
+
return n === accept?.network ? String(n) : `${n} (${accept.network})`;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** A chain a human recognises, for funding instructions. */
|
|
144
|
+
const CHAIN_NAMES = { 1: 'Ethereum', 10: 'Optimism', 137: 'Polygon', 480: 'World Chain', 4663: 'Robinhood Chain', 8453: 'Base', 42161: 'Arbitrum' };
|
|
145
|
+
export function chainName(network) {
|
|
146
|
+
const m = /^eip155:(\d+)$/.exec(String(network || ''));
|
|
147
|
+
return (m && CHAIN_NAMES[Number(m[1])]) || network;
|
|
148
|
+
}
|
|
149
|
+
|
|
126
150
|
const memoKey = (a) => `${a?.network || ''}|${a?.asset || ''}`;
|
|
127
151
|
|
|
128
152
|
/** lastGood first, known-underfunded last (never dropped — only deprioritised). */
|
|
@@ -197,8 +221,12 @@ export class PayClient {
|
|
|
197
221
|
const b = await tokenBalance(this.connection, this.keypair.publicKey, accept.asset);
|
|
198
222
|
return BigInt(b.raw || 0);
|
|
199
223
|
}
|
|
224
|
+
// By chain id, for the reason spelled out in buildPaymentFor: the rail name
|
|
225
|
+
// cannot tell one non-Base EVM chain from another.
|
|
226
|
+
const rpcUrl = evmRpcForNetwork(accept.network) ?? evmRpcFor(rail);
|
|
227
|
+
if (!rpcUrl) return 0n;
|
|
200
228
|
const raw = await evmTokenBalance({
|
|
201
|
-
rpcUrl
|
|
229
|
+
rpcUrl, token: accept.asset, owner: this.evmAddress,
|
|
202
230
|
});
|
|
203
231
|
return BigInt(raw || 0);
|
|
204
232
|
}
|
|
@@ -253,8 +281,26 @@ export class PayClient {
|
|
|
253
281
|
// Preflight: check the settlement-asset balance so an unfundable payment
|
|
254
282
|
// fails HERE with funding instructions instead of at the facilitator.
|
|
255
283
|
// Advisory only — an unreachable RPC never blocks a payment attempt.
|
|
256
|
-
|
|
284
|
+
//
|
|
285
|
+
// THE RPC IS CHOSEN BY THE ROW'S CHAIN ID, NEVER BY ITS RAIL NAME.
|
|
286
|
+
// railOf() calls every chain that is not Base or Robinhood "evm", so the
|
|
287
|
+
// old `rail === 'base' ? baseRpc : rhRpc` sent a POLYGON row's balance
|
|
288
|
+
// read to Robinhood Chain, where that token address is not a contract.
|
|
289
|
+
// The read threw, the catch below swallowed it, and this signed an
|
|
290
|
+
// authorization for a chain holding nothing — the facilitator reverted
|
|
291
|
+
// it and the caller was told "execution reverted" with a funded Base
|
|
292
|
+
// wallet one row away. MEASURED 2026-09-09.
|
|
293
|
+
const rpcUrl = evmRpcForNetwork(accept.network);
|
|
257
294
|
const owner = this.evmAddress;
|
|
295
|
+
// A CHAIN WE CANNOT READ IS NOT A CHAIN WE SIGN FOR. Skipping the row
|
|
296
|
+
// costs a caller nothing — the walk moves to the next one and reports
|
|
297
|
+
// honestly if none is payable — while signing it blind spends the call
|
|
298
|
+
// on a revert and teaches the caller nothing about why.
|
|
299
|
+
if (owner && !rpcUrl) {
|
|
300
|
+
throw new UnderfundedError(accept, null, owner, {
|
|
301
|
+
line: `No RPC configured for ${accept.network}, so this wallet's balance there cannot be checked — skipping that rail rather than signing blind. Set OPENZOO_POLYGON_RPC (or the matching chain's RPC) to use it.`,
|
|
302
|
+
});
|
|
303
|
+
}
|
|
258
304
|
if (owner) {
|
|
259
305
|
const bal = await evmTokenBalance({ rpcUrl, token: accept.asset, owner }).catch(() => null);
|
|
260
306
|
if (bal !== null && bal < BigInt(accept.maxAmountRequired)) {
|
|
@@ -263,7 +309,7 @@ export class PayClient {
|
|
|
263
309
|
// Paxos USDG, which implements EIP-3009 directly, so the only
|
|
264
310
|
// question left on every EVM rail is whether the wallet holds enough.
|
|
265
311
|
const sym = accept?.extra?.symbol || 'the quoted asset';
|
|
266
|
-
const where = rail === 'robinhood' ? 'Robinhood Chain' : rail === 'base' ? 'Base' : accept.network;
|
|
312
|
+
const where = rail === 'robinhood' ? 'Robinhood Chain' : rail === 'base' ? 'Base' : chainName(accept.network);
|
|
267
313
|
throw new UnderfundedError(accept, null, owner, {
|
|
268
314
|
line: await withOnrampLink(
|
|
269
315
|
`Send a few cents of ${sym} on ${where} to ${owner}.`,
|
|
@@ -340,6 +386,7 @@ export class PayClient {
|
|
|
340
386
|
}
|
|
341
387
|
try {
|
|
342
388
|
payment = await this.buildPaymentFor(cand, onStage, quote);
|
|
389
|
+
if (fundErrs.length) railNote(`paying on ${railLabel(cand)}`);
|
|
343
390
|
accept = cand;
|
|
344
391
|
lastGoodAsset = memoKey(cand);
|
|
345
392
|
underfundedUntil.delete(memoKey(cand));
|
|
@@ -352,6 +399,15 @@ export class PayClient {
|
|
|
352
399
|
// Remember it so the next call does not re-pay the discovery cost.
|
|
353
400
|
underfundedUntil.set(memoKey(cand), Date.now() + RAIL_MEMO_MS);
|
|
354
401
|
fundErrs.push({ sym: cand?.extra?.symbol || cand?.asset, err: e });
|
|
402
|
+
// SAY WHICH RAIL WAS PASSED OVER AND WHY.
|
|
403
|
+
//
|
|
404
|
+
// A caller whose wallet is funded on one chain and empty on five
|
|
405
|
+
// others sees only the LAST row's failure, and the chain that failed
|
|
406
|
+
// is not the chain they funded. Reading that as "my deposit is not
|
|
407
|
+
// seen" is the correct reading of what they were shown — the walk was
|
|
408
|
+
// silent about the five it skipped. One line per skip, on stderr,
|
|
409
|
+
// makes the choice inspectable without a flag or a debug build.
|
|
410
|
+
railNote(`skipping ${railLabel(cand)} — ${e.message.replace(/^openzoo wallet underfunded: /, '')}`);
|
|
355
411
|
}
|
|
356
412
|
}
|
|
357
413
|
if (!payment) {
|
package/lib/proxy.js
CHANGED
|
@@ -14,6 +14,7 @@ import { tokenBalance } from './x402.js';
|
|
|
14
14
|
import { evmTokenBalance } from './evm.js';
|
|
15
15
|
import { autoContext } from './autobind.js';
|
|
16
16
|
import { modelsListForRequest, isHarnessAliasId, resolveModel, quoteableRows, unopenrouter } from './models.js';
|
|
17
|
+
import { detectModelCommand, applyModelCommand, serveModelCommand, readOverride } from './modelcommand.js';
|
|
17
18
|
|
|
18
19
|
/**
|
|
19
20
|
* Quoteable catalog ids, cached 5 minutes, for the fuzzy /v1/models/<id> probe.
|
|
@@ -440,6 +441,10 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
|
|
|
440
441
|
lastQuoteUsd = n > 1 ? billed / n : billed;
|
|
441
442
|
};
|
|
442
443
|
let tunnelSpent = 0;
|
|
444
|
+
// Last `from -> to` model rewrite we announced. The override applies to EVERY
|
|
445
|
+
// paid request, and saying so on every one buries the payment lines it sits
|
|
446
|
+
// next to; the user needs to know once, when it changes.
|
|
447
|
+
let saidOverride = null;
|
|
443
448
|
// Live balance refresh state — the real implementation is assigned in the
|
|
444
449
|
// banner section below; the handler only ever calls scheduleRefresh().
|
|
445
450
|
let lastSnap = null;
|
|
@@ -763,6 +768,40 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
|
|
|
763
768
|
servedRequests += 1;
|
|
764
769
|
say(`\n<- request #${servedRequests} from ${(req.headers['user-agent'] || 'unknown').slice(0, 40)}`);
|
|
765
770
|
try { wantsStream = JSON.parse(bodyBuf.toString('utf8'))?.stream === true; } catch { /* not JSON */ }
|
|
771
|
+
// `/model <name>` IS A COMMAND, NOT A QUESTION.
|
|
772
|
+
//
|
|
773
|
+
// A user typed `/model fable-5` into ChatGPT desktop (Codex side, pointed
|
|
774
|
+
// here). The app has no such command, so it sent the line to the model as
|
|
775
|
+
// an ordinary turn — which then spent six minutes and real money shelling
|
|
776
|
+
// around the gateway trying to work out what was being asked, and the
|
|
777
|
+
// model never changed. Answer it here instead: no upstream call, no 402,
|
|
778
|
+
// no payment, and the choice is remembered in ~/.openzoo/model so it
|
|
779
|
+
// outlives the proxy. Must sit AHEAD of the replay key and the pay path.
|
|
780
|
+
try {
|
|
781
|
+
const parsed = JSON.parse(bodyBuf.toString('utf8'));
|
|
782
|
+
const cmd = parsed ? detectModelCommand(parsed) : null;
|
|
783
|
+
if (cmd) {
|
|
784
|
+
let ids = [];
|
|
785
|
+
try { ids = await catalogIdsCached(`${config.apiBase}/v1/models`, upstreamHeaders(req)); } catch { /* offline: applyModelCommand takes the id as typed */ }
|
|
786
|
+
const out = applyModelCommand(cmd.arg, ids);
|
|
787
|
+
say(` /model ${cmd.arg || '(status)'} — answered locally, nothing paid`);
|
|
788
|
+
if (out.changed) saidOverride = null;
|
|
789
|
+
serveModelCommand(res, {
|
|
790
|
+
rawPath, stream: wantsStream, text: out.text, model: out.model, serveAsSse,
|
|
791
|
+
});
|
|
792
|
+
return;
|
|
793
|
+
}
|
|
794
|
+
// THE OVERRIDE ITSELF. Applied before the replay key so a switched
|
|
795
|
+
// model is a different cache entry, and before pay so the 402 is quoted
|
|
796
|
+
// for the model that will actually answer.
|
|
797
|
+
const override = readOverride();
|
|
798
|
+
if (override && parsed && typeof parsed.model === 'string' && parsed.model !== override) {
|
|
799
|
+
const pair = `${parsed.model} -> ${override}`;
|
|
800
|
+
if (saidOverride !== pair) { say(` model override: ${pair}`); saidOverride = pair; }
|
|
801
|
+
parsed.model = override;
|
|
802
|
+
bodyBuf = Buffer.from(JSON.stringify(parsed));
|
|
803
|
+
}
|
|
804
|
+
} catch { /* not JSON */ }
|
|
766
805
|
// UNOPENROUTER THE MODEL ID before anything downstream sees the body —
|
|
767
806
|
// the replay key, the outage gate, the wire. A vendor-prefixed
|
|
768
807
|
// OpenRouter spelling becomes the bare id the doors serve when the
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "openzoo",
|
|
3
|
-
"version": "0.51.
|
|
4
|
-
"description": "Local x402-paying proxy + MCP server for openzoo.fun
|
|
3
|
+
"version": "0.51.6",
|
|
4
|
+
"description": "Local x402-paying proxy + MCP server for openzoo.fun \u2014 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",
|
|
7
7
|
"bin": {
|
|
@@ -10,8 +10,7 @@
|
|
|
10
10
|
"files": [
|
|
11
11
|
"bin",
|
|
12
12
|
"lib",
|
|
13
|
-
"README.md"
|
|
14
|
-
"transmute"
|
|
13
|
+
"README.md"
|
|
15
14
|
],
|
|
16
15
|
"engines": {
|
|
17
16
|
"node": ">=18"
|
|
@@ -26,9 +25,7 @@
|
|
|
26
25
|
"dugite": "^3.2.3",
|
|
27
26
|
"selfsigned": "^5.5.0",
|
|
28
27
|
"viem": "^2.21.0",
|
|
29
|
-
"zod": "^3.24.0"
|
|
30
|
-
"acorn": "^8.14.0",
|
|
31
|
-
"acorn-walk": "^8.3.4"
|
|
28
|
+
"zod": "^3.24.0"
|
|
32
29
|
},
|
|
33
30
|
"repository": {
|
|
34
31
|
"type": "git",
|