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 +1 -1
- package/lib/config.js +1 -1
- package/lib/modelcommand.js +98 -8
- package/lib/proxy.js +17 -5
- package/package.json +1 -1
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://
|
|
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://
|
|
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).
|
package/lib/modelcommand.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
338
|
+
writeOverrideFor(key, wanted, file);
|
|
249
339
|
return {
|
|
250
|
-
text: `Could not reach the model catalog, so I took "${wanted}" as typed.
|
|
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
|
-
|
|
354
|
+
writeOverrideFor(key, hit, file);
|
|
265
355
|
return {
|
|
266
|
-
text: `Switched to ${hit}.
|
|
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,
|
|
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
|
|
833
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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",
|