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.
Files changed (55) hide show
  1. package/README.md +12 -1
  2. package/bin/openzoo.js +21 -46
  3. package/lib/chatgpt-install.js +106 -0
  4. package/lib/chatgpt.js +108 -90
  5. package/lib/config.js +34 -0
  6. package/lib/modelcommand.js +443 -0
  7. package/lib/pay.js +60 -4
  8. package/lib/proxy.js +39 -0
  9. package/package.json +4 -7
  10. package/transmute/README.md +0 -385
  11. package/transmute/bin/openzoo-transmute.js +0 -7
  12. package/transmute/docs/OPENZOO_CLI_PATCH.md +0 -139
  13. package/transmute/docs/VERCEL_TO_SOLANA.md +0 -381
  14. package/transmute/lib/build.js +0 -381
  15. package/transmute/lib/cli.js +0 -293
  16. package/transmute/lib/compile/bytecode.js +0 -591
  17. package/transmute/lib/compile/hostids.js +0 -11
  18. package/transmute/lib/compile/index.js +0 -159
  19. package/transmute/lib/compile/ir.js +0 -1634
  20. package/transmute/lib/compile/parse.js +0 -753
  21. package/transmute/lib/compile/rust.js +0 -781
  22. package/transmute/lib/deploy.js +0 -357
  23. package/transmute/lib/eligibility.js +0 -211
  24. package/transmute/lib/gateway.js +0 -710
  25. package/transmute/lib/hub.js +0 -290
  26. package/transmute/lib/solana.js +0 -387
  27. package/transmute/lib/vercel.js +0 -296
  28. package/transmute/lib/wallet.js +0 -41
  29. package/transmute/lib/wire.js +0 -208
  30. package/transmute/prebuilt/zoo_vm.v0.so +0 -0
  31. package/transmute/runtime/zoo-host/Cargo.lock +0 -588
  32. package/transmute/runtime/zoo-host/Cargo.toml +0 -30
  33. package/transmute/runtime/zoo-host/examples/vmrun.rs +0 -43
  34. package/transmute/runtime/zoo-host/src/assets.rs +0 -214
  35. package/transmute/runtime/zoo-host/src/ctx.rs +0 -350
  36. package/transmute/runtime/zoo-host/src/fmt.rs +0 -188
  37. package/transmute/runtime/zoo-host/src/helpers.rs +0 -245
  38. package/transmute/runtime/zoo-host/src/json.rs +0 -263
  39. package/transmute/runtime/zoo-host/src/kv.rs +0 -206
  40. package/transmute/runtime/zoo-host/src/lib.rs +0 -137
  41. package/transmute/runtime/zoo-host/src/site.rs +0 -94
  42. package/transmute/runtime/zoo-host/src/val.rs +0 -1050
  43. package/transmute/runtime/zoo-host/src/vm.rs +0 -902
  44. package/transmute/runtime/zoo-host/src/wire.rs +0 -239
  45. package/transmute/runtime/zoo-vm/Cargo.lock +0 -596
  46. package/transmute/runtime/zoo-vm/Cargo.toml +0 -23
  47. package/transmute/runtime/zoo-vm/src/lib.rs +0 -92
  48. package/transmute/site/.env.production +0 -2
  49. package/transmute/site/api/echo.js +0 -10
  50. package/transmute/site/api/hello.js +0 -12
  51. package/transmute/site/api/hits.js +0 -13
  52. package/transmute/site/api/time.js +0 -5
  53. package/transmute/site/package.json +0 -6
  54. package/transmute/site/public/app.js +0 -16
  55. 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: evmRpcFor(rail), token: accept.asset, owner: this.evmAddress,
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
- const rpcUrl = rail === 'base' ? config.baseRpcUrl : config.rhRpcUrl;
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",
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. `openzoo build|deploy|serve|hub|runtime` transmute a Vercel-shaped app (Next.js / Vite + /api) into Solana accounts on the shared zoo-vm Pinocchio runtime (~0.05 SOL a site) or a dedicated program, mainnet.",
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",