openzoo 0.51.23 → 0.51.26
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/proxy.js +110 -10
- package/lib/responsestools.js +0 -0
- package/package.json +1 -1
package/lib/proxy.js
CHANGED
|
@@ -27,16 +27,27 @@ import { detectModelCommand, applyModelCommand, serveModelCommand, readOverride
|
|
|
27
27
|
* Free read, but harnesses probe on every /model keystroke confirmation —
|
|
28
28
|
* without a cache each probe is an upstream round trip.
|
|
29
29
|
*/
|
|
30
|
-
let catalogIdsMemo = { at: 0, ids: [] };
|
|
30
|
+
let catalogIdsMemo = { at: 0, ids: [], house: new Set() };
|
|
31
31
|
async function catalogIdsCached(modelsUrl, headers) {
|
|
32
32
|
if (Date.now() - catalogIdsMemo.at < 5 * 60_000 && catalogIdsMemo.ids.length) return catalogIdsMemo.ids;
|
|
33
33
|
const r = await fetch(modelsUrl, { headers, signal: AbortSignal.timeout(8000) });
|
|
34
34
|
if (!r.ok) throw new Error(`catalog ${r.status}`);
|
|
35
35
|
const payload = await r.json();
|
|
36
|
-
const
|
|
37
|
-
|
|
36
|
+
const rows = quoteableRows(payload?.data);
|
|
37
|
+
const ids = rows.map((m) => m.id);
|
|
38
|
+
// House doors (`owned_by: "openzoo"` — abliterated-model*, openzoo/auto)
|
|
39
|
+
// take plain `function` tools only; see responsestools.js.
|
|
40
|
+
const house = new Set(rows.filter((m) => m.owned_by === 'openzoo').map((m) => m.id));
|
|
41
|
+
if (ids.length) catalogIdsMemo = { at: Date.now(), ids, house };
|
|
38
42
|
return ids;
|
|
39
43
|
}
|
|
44
|
+
/** Models we have SEEN reject Responses tool groups with `invalid_tools`,
|
|
45
|
+
* so the next request flattens before paying instead of after. */
|
|
46
|
+
const flattenLearned = new Set();
|
|
47
|
+
function wantsFlatTools(model) {
|
|
48
|
+
const id = String(model || '');
|
|
49
|
+
return flattenLearned.has(id) || catalogIdsMemo.house.has(id) || /^abliterated-model/.test(id);
|
|
50
|
+
}
|
|
40
51
|
import { withNamespace } from './namespace.js';
|
|
41
52
|
import { loadSessionSpend, saveSessionSpend, sessionSpendFile } from './session.js';
|
|
42
53
|
import { creditBalance, quotedPrices } from './info.js';
|
|
@@ -44,6 +55,7 @@ import { priceHoldings } from './livestatus.js';
|
|
|
44
55
|
import { receiptUsedCogs, receiptDirectUsd, pairActualBilled } from './racesettle.js';
|
|
45
56
|
import { fetchHeaders } from './fetch.js';
|
|
46
57
|
import { attachX402Proof } from './spendProof.js';
|
|
58
|
+
import { flattenResponsesTools, restoreResponsesPayload, sseNamespaceRestorer, isInvalidToolsError } from './responsestools.js';
|
|
47
59
|
|
|
48
60
|
/** Kill whatever is LISTEN on this port except this process.
|
|
49
61
|
* lsof first (macOS), then fuser, then /proc (Omarchy/Arch with neither).
|
|
@@ -224,7 +236,7 @@ async function readBody(req) {
|
|
|
224
236
|
*
|
|
225
237
|
* Sniffing NEVER delays a byte: each chunk is written to the client first and
|
|
226
238
|
* only then scanned. */
|
|
227
|
-
function relay(res, upstream, onReceipt) {
|
|
239
|
+
function relay(res, upstream, onReceipt, rewriter = null) {
|
|
228
240
|
const headers = {};
|
|
229
241
|
upstream.headers.forEach((v, k) => {
|
|
230
242
|
if (!['transfer-encoding', 'connection', 'content-encoding', 'content-length'].includes(k)) headers[k] = v;
|
|
@@ -237,13 +249,20 @@ function relay(res, upstream, onReceipt) {
|
|
|
237
249
|
body.on('error', () => res.destroy());
|
|
238
250
|
res.on('close', () => body.destroy());
|
|
239
251
|
body.on('end', resolve);
|
|
240
|
-
if (!sse || typeof onReceipt !== 'function') { body.pipe(res); return; }
|
|
241
|
-
const observe = billingObserver(onReceipt);
|
|
252
|
+
if (!sse || (typeof onReceipt !== 'function' && !rewriter)) { body.pipe(res); return; }
|
|
253
|
+
const observe = typeof onReceipt === 'function' ? billingObserver(onReceipt) : () => {};
|
|
254
|
+
// A rewriter (Responses tool un-flattening) needs whole `data:` lines, so
|
|
255
|
+
// it re-chunks on newlines; the billing sniff reads the rewritten text,
|
|
256
|
+
// which leaves the trailing receipt comment untouched.
|
|
242
257
|
body.on('data', (c) => {
|
|
243
|
-
|
|
244
|
-
|
|
258
|
+
const text = rewriter ? rewriter.push(c.toString('utf8')) : c;
|
|
259
|
+
if (text.length) res.write(text);
|
|
260
|
+
observe(text.toString('utf8'));
|
|
261
|
+
});
|
|
262
|
+
body.on('end', () => {
|
|
263
|
+
if (rewriter) { const rest = rewriter.flush(); if (rest) { res.write(rest); observe(rest); } }
|
|
264
|
+
res.end();
|
|
245
265
|
});
|
|
246
|
-
body.on('end', () => res.end());
|
|
247
266
|
});
|
|
248
267
|
}
|
|
249
268
|
|
|
@@ -769,9 +788,13 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
|
|
|
769
788
|
// The BODY IS NOT TOUCHED — the parse below is read-only, to learn the
|
|
770
789
|
// client's streaming intent for the JSON→SSE compatibility path.
|
|
771
790
|
const isChat = req.method === 'POST' && rawPath.includes('/chat/completions');
|
|
791
|
+
const isResponses = req.method === 'POST' && /\/responses$/.test(rawPath);
|
|
772
792
|
const isPaidPost = req.method === 'POST'
|
|
773
793
|
&& /\/(chat\/completions|completions|messages|responses)$/.test(rawPath);
|
|
774
794
|
let wantsStream = false;
|
|
795
|
+
// Flat-name → {namespace, name} for this request's Responses tools; set
|
|
796
|
+
// only when the body was flattened, so the reply can be un-flattened.
|
|
797
|
+
let nsMap = null;
|
|
775
798
|
// RECEIPTS ARE OPT-OUT. `disableStats: true` in the body (or the
|
|
776
799
|
// x-openzoo-disable-stats header) means this caller does not want the x402
|
|
777
800
|
// block, so we neither attach our settle proof nor pass the gateway's
|
|
@@ -843,6 +866,23 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
|
|
|
843
866
|
}
|
|
844
867
|
}
|
|
845
868
|
} catch { /* not JSON */ }
|
|
869
|
+
// RESPONSES TOOL GROUPS vs HOUSE DOORS. Codex sends `namespace` tool
|
|
870
|
+
// groups + `web_search`; abliterated-model* rejects both with a PAID
|
|
871
|
+
// 400 `invalid_tools`. Flatten before the wire for models known to
|
|
872
|
+
// need it (catalog house doors, or learned from a prior 400 below).
|
|
873
|
+
if (isResponses) {
|
|
874
|
+
try {
|
|
875
|
+
const parsed = JSON.parse(bodyBuf.toString('utf8'));
|
|
876
|
+
if (parsed && wantsFlatTools(parsed.model)) {
|
|
877
|
+
const flat = flattenResponsesTools(parsed);
|
|
878
|
+
if (flat.map) {
|
|
879
|
+
nsMap = flat.map;
|
|
880
|
+
bodyBuf = Buffer.from(JSON.stringify(flat.body));
|
|
881
|
+
say(` tools flattened for ${parsed.model}: ${flat.map.size} namespaced → function${flat.dropped.length ? `, dropped ${flat.dropped.join(', ')}` : ''}`);
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
} catch { /* not JSON */ }
|
|
885
|
+
}
|
|
846
886
|
}
|
|
847
887
|
|
|
848
888
|
// Retry of a body we answered seconds ago? Serve the cached completion —
|
|
@@ -958,6 +998,56 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
|
|
|
958
998
|
result = await client.fetch(url, init);
|
|
959
999
|
}
|
|
960
1000
|
}
|
|
1001
|
+
// A 5xx IS NOT A REASON TO CHANGE THE MODEL. An earlier build routed any
|
|
1002
|
+
// gateway 5xx to OPENZOO_OUTAGE_FALLBACK and gated the model 60s. That
|
|
1003
|
+
// was wrong twice over: `/model fable-5.1` then reported `x402 door for
|
|
1004
|
+
// grok-4.6 failed`, so the caller read an error for a model they never
|
|
1005
|
+
// picked, and a silent substitution bills them for a DIFFERENT model's
|
|
1006
|
+
// answer than the one they chose and are reading. When the requested
|
|
1007
|
+
// model's door is down, say so about THAT model and let the caller
|
|
1008
|
+
// decide. Only the 402 "insufficient credits" branch above still
|
|
1009
|
+
// reroutes, because there the gateway itself refunds and the request
|
|
1010
|
+
// was never served at all.
|
|
1011
|
+
const RETRYABLE_5XX = new Set([500, 502, 503, 504]);
|
|
1012
|
+
if (RETRYABLE_5XX.has(result.response?.status)) {
|
|
1013
|
+
let detail = '';
|
|
1014
|
+
try { const j = await result.response.clone().json(); detail = String(j?.error?.message || j?.error || '').slice(0, 300); } catch { /* opaque 5xx */ }
|
|
1015
|
+
log(`upstream ${result.response.status} for ${outageKey(init)}${detail ? `: ${detail}` : ''} — surfacing as-is, model NOT swapped`);
|
|
1016
|
+
// "no door quoted <model>: … wrong asset/rail (base)" IS AN EMPTY
|
|
1017
|
+
// WALLET WEARING A 502. Measured 2026-09-13: Solana USDC ran down to
|
|
1018
|
+
// dust, the rail picker fell through to Base (which held $0.0016), and
|
|
1019
|
+
// every door that only quotes Solana refused the offer — grok-4.6,
|
|
1020
|
+
// deepseek and fable all "502"d while abliterated-model (~$0.0005/call)
|
|
1021
|
+
// still paid out of the dust and looked fine. The raw 502 sent the
|
|
1022
|
+
// harness into a reconnect storm and read like a dead model. Say what
|
|
1023
|
+
// it is, and name the model the caller actually asked for.
|
|
1024
|
+
if (/no door quoted|wrong asset\/rail/i.test(detail)) {
|
|
1025
|
+
const rail = /\(([^)]+)\)/.exec(detail.match(/wrong asset\/rail \([^)]*\)/i)?.[0] || '')?.[1] || '';
|
|
1026
|
+
let msg = `${outageKey(init)} could not be paid for: the x402 door refused the offer${rail ? ` on the ${rail} rail` : ''}. This is almost always an empty wallet on the rail that door takes — the request never reached the model, and nothing was charged.`;
|
|
1027
|
+
msg = await withOnrampLink(msg, { solana: client.address, evm: client.evmAddress, force: true });
|
|
1028
|
+
jsonErr(res, 402, msg);
|
|
1029
|
+
return;
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
1032
|
+
// A PAID 400 `invalid_tools` on a Responses body we did not flatten:
|
|
1033
|
+
// this model is a function-tools-only door we had not learned yet.
|
|
1034
|
+
// Flatten, remember it, and buy the turn once more — one billed miss
|
|
1035
|
+
// instead of every turn for the rest of the session.
|
|
1036
|
+
if (isResponses && !nsMap && result.response?.status === 400) {
|
|
1037
|
+
let e400 = null;
|
|
1038
|
+
try { e400 = await result.response.clone().json(); } catch { e400 = null; }
|
|
1039
|
+
if (isInvalidToolsError(e400)) {
|
|
1040
|
+
let flat = null;
|
|
1041
|
+
try { flat = flattenResponsesTools(JSON.parse(String(init.body || '{}'))); } catch { flat = null; }
|
|
1042
|
+
if (flat?.map) {
|
|
1043
|
+
flattenLearned.add(outageKey(init));
|
|
1044
|
+
nsMap = flat.map;
|
|
1045
|
+
init = { ...init, body: JSON.stringify(flat.body) };
|
|
1046
|
+
say(` invalid_tools from ${outageKey(init)} — flattened ${flat.map.size} namespaced tools${flat.dropped.length ? `, dropped ${flat.dropped.join(', ')}` : ''}; retrying`);
|
|
1047
|
+
result = await client.fetch(url, init);
|
|
1048
|
+
}
|
|
1049
|
+
}
|
|
1050
|
+
}
|
|
961
1051
|
const { response, paid, receipt, accept } = result;
|
|
962
1052
|
if (paid && receipt) {
|
|
963
1053
|
if (receipt.ok && typeof receipt.billedUsd === 'number') {
|
|
@@ -1038,6 +1128,16 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
|
|
|
1038
1128
|
res.end(JSON.stringify(data));
|
|
1039
1129
|
return;
|
|
1040
1130
|
}
|
|
1131
|
+
// Un-flatten a JSON Responses reply so Codex can route the calls.
|
|
1132
|
+
if (nsMap && data?.object === 'response') {
|
|
1133
|
+
restoreResponsesPayload(data, nsMap);
|
|
1134
|
+
const h = { 'content-type': 'application/json' };
|
|
1135
|
+
const settleHdr = response.headers.get('x-payment-response');
|
|
1136
|
+
if (settleHdr) h['x-payment-response'] = settleHdr;
|
|
1137
|
+
res.writeHead(200, h);
|
|
1138
|
+
res.end(JSON.stringify(data));
|
|
1139
|
+
return;
|
|
1140
|
+
}
|
|
1041
1141
|
}
|
|
1042
1142
|
// A STREAMED call is metered from the gateway's trailing SSE comment —
|
|
1043
1143
|
// same figures the JSON path reads out of `data.x402`, same counters, so
|
|
@@ -1085,7 +1185,7 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
|
|
|
1085
1185
|
paymentError(res, req, init, paid ? copy.status : 402, msg);
|
|
1086
1186
|
return;
|
|
1087
1187
|
}
|
|
1088
|
-
await relay(res, response, meterStreamed);
|
|
1188
|
+
await relay(res, response, meterStreamed, nsMap ? sseNamespaceRestorer(nsMap) : null);
|
|
1089
1189
|
} catch (err) {
|
|
1090
1190
|
if (err instanceof QuoteTooHighError) {
|
|
1091
1191
|
log(err.message);
|
|
Binary file
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "openzoo",
|
|
3
|
-
"version": "0.51.
|
|
3
|
+
"version": "0.51.26",
|
|
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",
|