openzoo 0.51.28 → 0.51.30

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 CHANGED
@@ -283,7 +283,7 @@ The rail is chosen from the 402's `accepts[]` itself (Solana first). **Steer it
283
283
  | `OPENZOO_RPC` | mainnet-beta public RPC | Solana RPC |
284
284
  | `OPENZOO_TOKEN` | (internal) | preferred 402 rail — leave unset |
285
285
  | `OPENZOO_RAIL` | (unset) | force a rail: `solana` \| `base` \| `robinhood`. Errors if the live 402 doesn't offer it |
286
- | `OPENZOO_BASE_RPC` | `https://mainnet.base.org` | Base RPC (balances / preflight) |
286
+ | `OPENZOO_BASE_RPC` | `https://base-rpc.publicnode.com` | Base RPC (balances / preflight) |
287
287
  | `OPENZOO_RH_RPC` | `https://rpc.mainnet.chain.robinhood.com` | Robinhood Chain RPC (balances / preflight) |
288
288
  | `OPENZOO_WALLET` | `~/.openzoo/wallet.json` | wallet path |
289
289
  | `OPENZOO_MAX_USD_PER_CALL` | `0.5` | refuse quotes above this |
package/lib/config.js CHANGED
@@ -20,7 +20,7 @@ export const config = {
20
20
  // pickAccept errors clearly when the live 402 does not offer the forced rail.
21
21
  rail: (process.env.OPENZOO_RAIL || '').toLowerCase() || null,
22
22
  // EVM RPCs for balance reads / preflight checks on the Base and RH rails.
23
- baseRpcUrl: process.env.OPENZOO_BASE_RPC || 'https://mainnet.base.org',
23
+ baseRpcUrl: process.env.OPENZOO_BASE_RPC || 'https://base-rpc.publicnode.com',
24
24
  rhRpcUrl: process.env.OPENZOO_RH_RPC || 'https://rpc.mainnet.chain.robinhood.com',
25
25
  walletPath: process.env.OPENZOO_WALLET || path.join(os.homedir(), '.openzoo', 'wallet.json'),
26
26
  // Refuse to auto-pay any single 402 quote above this many USD (extra.billedUsd).
@@ -217,23 +217,113 @@ export function clearOverride(file = modelFilePath()) {
217
217
  memo = { file: null, stamp: null, id: null };
218
218
  }
219
219
 
220
+ // ---------------------------------------------------------------------------
221
+ // PER-CHAT OVERRIDES — `~/.openzoo/models.json`, keyed by the harness's own
222
+ // conversation id.
223
+ //
224
+ // THE FAILURE THIS EXISTS FOR. `~/.openzoo/model` is ONE line for the whole
225
+ // machine, so two ChatGPT windows could not hold two models: `/model
226
+ // abliterated` in window A and `/model fable-5.1` in window B left BOTH on
227
+ // whichever was typed last. Window A then answered as fable and looked like it
228
+ // was lying about its own identity. Codex sends a stable per-conversation id
229
+ // (`thread-id` / `session-id` headers, `prompt_cache_key`, and `session_id`
230
+ // inside client_metadata), all equal per chat and different across chats, so
231
+ // the override can be keyed by chat and the windows stop fighting.
232
+ //
233
+ // The single-line file stays the machine-wide DEFAULT: a harness that sends no
234
+ // conversation id (curl, `openzoo model X`, older clients) still reads and
235
+ // writes it, and a chat with no entry of its own falls back to it.
236
+ // ---------------------------------------------------------------------------
237
+
238
+ export function chatModelsPath(home = os.homedir()) {
239
+ return path.join(home, '.openzoo', 'models.json');
240
+ }
241
+
242
+ /** The conversation id for this request, or '' when the caller has no notion
243
+ * of one. Header first (cheapest, and Codex sets several that agree); the
244
+ * window id carries a `:0` pane suffix that must survive — two panes of one
245
+ * window are two chats. */
246
+ export function chatKeyFrom(headers = {}, body = null) {
247
+ const h = (k) => {
248
+ const v = headers?.[k] ?? headers?.[String(k).toLowerCase()];
249
+ return Array.isArray(v) ? v[0] : v;
250
+ };
251
+ const fromHeader = h('thread-id') || h('session-id') || h('x-codex-window-id');
252
+ if (fromHeader) return String(fromHeader).trim();
253
+ if (body && typeof body.prompt_cache_key === 'string' && body.prompt_cache_key) {
254
+ return body.prompt_cache_key.trim();
255
+ }
256
+ try {
257
+ const meta = body?.client_metadata;
258
+ const sid = meta?.session_id
259
+ || JSON.parse(String(meta?.['x-codex-turn-metadata'] || '{}'))?.session_id;
260
+ if (sid) return String(sid).trim();
261
+ } catch { /* metadata is advisory */ }
262
+ return '';
263
+ }
264
+
265
+ function readChatMap(file = chatModelsPath()) {
266
+ try {
267
+ const j = JSON.parse(readFileSync(file, 'utf8'));
268
+ return j && typeof j === 'object' && !Array.isArray(j) ? j : {};
269
+ } catch { return {}; }
270
+ }
271
+
272
+ function writeChatMap(map, file = chatModelsPath()) {
273
+ mkdirSync(path.dirname(file), { recursive: true });
274
+ // Newest last, capped: a long-lived proxy would otherwise accumulate an
275
+ // entry per chat forever. 500 is far past any real window count.
276
+ const keys = Object.keys(map);
277
+ const trimmed = keys.length > 500
278
+ ? Object.fromEntries(keys.slice(-500).map((k) => [k, map[k]]))
279
+ : map;
280
+ writeFileSync(file, `${JSON.stringify(trimmed, null, 2)}\n`);
281
+ }
282
+
283
+ /** This chat's model: its own entry, else the machine-wide default. */
284
+ export function readOverrideFor(key, file = modelFilePath(), chatFile = chatModelsPath()) {
285
+ if (key) {
286
+ const hit = readChatMap(chatFile)[key];
287
+ if (typeof hit === 'string' && hit) return hit;
288
+ }
289
+ return readOverride(file);
290
+ }
291
+
292
+ export function writeOverrideFor(key, id, file = modelFilePath(), chatFile = chatModelsPath()) {
293
+ if (!key) return writeOverride(id, file);
294
+ const map = readChatMap(chatFile);
295
+ map[key] = id;
296
+ writeChatMap(map, chatFile);
297
+ return id;
298
+ }
299
+
300
+ export function clearOverrideFor(key, file = modelFilePath(), chatFile = chatModelsPath()) {
301
+ if (!key) { clearOverride(file); return; }
302
+ const map = readChatMap(chatFile);
303
+ delete map[key];
304
+ writeChatMap(map, chatFile);
305
+ }
306
+
220
307
  const RESET_WORDS = new Set(['reset', 'default', 'off', 'none', 'clear']);
221
308
 
222
309
  /**
223
310
  * Run the command and return what to say back. Pure apart from the one file
224
311
  * write, so the reply text is testable.
225
312
  */
226
- export function applyModelCommand(arg, ids, file = modelFilePath()) {
227
- const current = readOverride(file);
313
+ export function applyModelCommand(arg, ids, file = modelFilePath(), key = '') {
314
+ // `key` is this chat's conversation id (see chatKeyFrom). Empty = the
315
+ // machine-wide default file, which is what non-Codex callers get.
316
+ const current = readOverrideFor(key, file);
317
+ const scope = key ? 'This chat' : 'Every chat';
228
318
  const wanted = String(arg || '').trim();
229
319
  if (!wanted) {
230
320
  return { text: statusText(current, ids), model: current || 'openzoo', changed: false };
231
321
  }
232
322
  if (RESET_WORDS.has(wanted.toLowerCase())) {
233
- clearOverride(file);
323
+ clearOverrideFor(key, file);
234
324
  return {
235
325
  text: current
236
- ? `Cleared the model override (was ${current}). Chats now use whatever the app sends.`
326
+ ? `Cleared the model override (was ${current}). ${key ? 'This chat' : 'Chats'} now use${key ? 's' : ''} whatever the app sends.`
237
327
  : 'No model override was set. Chats use whatever the app sends.',
238
328
  model: 'openzoo',
239
329
  changed: Boolean(current),
@@ -245,9 +335,9 @@ export function applyModelCommand(arg, ids, file = modelFilePath()) {
245
335
  // the command useless exactly when the gateway is flaky, so take the id as
246
336
  // typed and say so — the next real request surfaces a wrong id anyway.
247
337
  if (!Array.isArray(ids) || !ids.length) {
248
- writeOverride(wanted, file);
338
+ writeOverrideFor(key, wanted, file);
249
339
  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.`,
340
+ text: `Could not reach the model catalog, so I took "${wanted}" as typed. ${scope} from here uses it until you send /model reset.`,
251
341
  model: wanted,
252
342
  changed: true,
253
343
  };
@@ -261,9 +351,9 @@ export function applyModelCommand(arg, ids, file = modelFilePath()) {
261
351
  changed: false,
262
352
  };
263
353
  }
264
- writeOverride(hit, file);
354
+ writeOverrideFor(key, hit, file);
265
355
  return {
266
- text: `Switched to ${hit}. Every chat from here uses it until you send /model reset.`,
356
+ text: `Switched to ${hit}. ${scope} from here uses it until you send /model reset.`,
267
357
  model: hit,
268
358
  changed: hit !== current,
269
359
  };
package/lib/proxy.js CHANGED
@@ -20,7 +20,7 @@ import { tokenBalance } from './x402.js';
20
20
  import { evmTokenBalance } from './evm.js';
21
21
  import { autoContext } from './autobind.js';
22
22
  import { modelsListForRequest, isHarnessAliasId, resolveModel, quoteableRows, unopenrouter } from './models.js';
23
- import { detectModelCommand, applyModelCommand, serveModelCommand, readOverride } from './modelcommand.js';
23
+ import { detectModelCommand, applyModelCommand, serveModelCommand, readOverrideFor, chatKeyFrom } from './modelcommand.js';
24
24
 
25
25
  /**
26
26
  * Quoteable catalog ids, cached 5 minutes, for the fuzzy /v1/models/<id> probe.
@@ -829,8 +829,9 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
829
829
  if (cmd) {
830
830
  let ids = [];
831
831
  try { ids = await catalogIdsCached(`${config.apiBase}/v1/models`, upstreamHeaders(req)); } catch { /* offline: applyModelCommand takes the id as typed */ }
832
- const out = applyModelCommand(cmd.arg, ids);
833
- say(` /model ${cmd.arg || '(status)'} — answered locally, nothing paid`);
832
+ const chatKey = chatKeyFrom(req.headers, parsed);
833
+ const out = applyModelCommand(cmd.arg, ids, undefined, chatKey);
834
+ say(` /model ${cmd.arg || '(status)'} — answered locally, nothing paid${chatKey ? ` (chat ${chatKey.slice(0, 8)})` : ''}`);
834
835
  if (out.changed) saidOverride = null;
835
836
  serveModelCommand(res, {
836
837
  rawPath, stream: wantsStream, text: out.text, model: out.model, serveAsSse,
@@ -840,7 +841,9 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
840
841
  // THE OVERRIDE ITSELF. Applied before the replay key so a switched
841
842
  // model is a different cache entry, and before pay so the 402 is quoted
842
843
  // for the model that will actually answer.
843
- const override = readOverride();
844
+ // Per-chat first, machine default second — two ChatGPT windows hold
845
+ // two different models because Codex keys each by thread-id.
846
+ const override = readOverrideFor(chatKeyFrom(req.headers, parsed));
844
847
  if (override && parsed && typeof parsed.model === 'string' && parsed.model !== override) {
845
848
  const pair = `${parsed.model} -> ${override}`;
846
849
  if (saidOverride !== pair) { say(` model override: ${pair}`); saidOverride = pair; }
@@ -963,7 +966,16 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
963
966
  // x402 door for bare grok-4.6 settles cogs on chain and never touches
964
967
  // OpenRouter. When a model is gated (or comes back "Insufficient
965
968
  // credits" below), the same request goes out once more on the fallback.
966
- const FALLBACK_MODEL = String(process.env.OPENZOO_OUTAGE_FALLBACK || 'grok-4.6');
969
+ // NEVER SWAP THE CALLER'S MODEL. This defaulted to `grok-4.6`, so an
970
+ // outage on the model you picked silently bought a DIFFERENT model —
971
+ // `/model abliterated` and `/model fable-5.1` both reported `x402 door
972
+ // for grok-4.6 failed`, an error naming a model the caller never chose,
973
+ // while billing them for an answer from that other model. Empty by
974
+ // default: every swap below is guarded by `if (FALLBACK_MODEL && …)`, so
975
+ // this one line turns them all off and the real error surfaces against
976
+ // the model that was actually requested. Set OPENZOO_OUTAGE_FALLBACK
977
+ // explicitly to opt back in.
978
+ const FALLBACK_MODEL = String(process.env.OPENZOO_OUTAGE_FALLBACK || '');
967
979
  const withModel = (i, model) => {
968
980
  try { const b = JSON.parse(String(i.body || '{}')); b.model = model; return { ...i, body: JSON.stringify(b) }; } catch { return i; }
969
981
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openzoo",
3
- "version": "0.51.28",
3
+ "version": "0.51.30",
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",