openzoo 0.10.0 → 0.11.0
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/bin/openzoo.js +49 -0
- package/lib/bindpath.js +143 -0
- package/lib/brief.js +17 -3
- package/lib/mcp.js +47 -1
- package/lib/proxy.js +21 -3
- package/package.json +1 -1
package/bin/openzoo.js
CHANGED
|
@@ -77,6 +77,55 @@ async function main() {
|
|
|
77
77
|
console.log('\nthese corpora never ship again — asks against them send only the question + X-HRR-Context.');
|
|
78
78
|
break;
|
|
79
79
|
}
|
|
80
|
+
case 'bind': {
|
|
81
|
+
const target = process.argv[3];
|
|
82
|
+
if (!target) throw new Error('usage: openzoo bind <file-or-directory> [--ext .txt,.md] [--force]');
|
|
83
|
+
const { bindPath } = await import('../lib/bindpath.js');
|
|
84
|
+
const ei = process.argv.indexOf('--ext');
|
|
85
|
+
const exts = ei !== -1 && process.argv[ei + 1]
|
|
86
|
+
? process.argv[ei + 1].split(',').map((e) => (e.startsWith('.') ? e : `.${e}`))
|
|
87
|
+
: undefined;
|
|
88
|
+
const mb = (n) => (n / 1048576).toFixed(1);
|
|
89
|
+
const out = await bindPath(target, {
|
|
90
|
+
exts,
|
|
91
|
+
force: process.argv.includes('--force'),
|
|
92
|
+
onProgress: (p) => {
|
|
93
|
+
if (p.stage === 'reused') console.log(`already bound — ${mb(p.bytes)}MB across ${p.files} file(s) reused, nothing uploaded`);
|
|
94
|
+
if (p.stage === 'start') console.log(`binding ${mb(p.bytes)}MB from ${p.files} file(s) in ${p.parts} part(s)...`);
|
|
95
|
+
if (p.stage === 'part') console.log(` part ${p.index}/${p.of} bound (${mb(p.bytes)}MB)`);
|
|
96
|
+
},
|
|
97
|
+
});
|
|
98
|
+
console.log('');
|
|
99
|
+
console.log(`context: ${out.contextId}`);
|
|
100
|
+
console.log(`ask it: npx openzoo ask "your question" --context ${out.contextId}`);
|
|
101
|
+
console.log('or send X-HRR-Context: <id> with a small body to /v1/chat/completions');
|
|
102
|
+
break;
|
|
103
|
+
}
|
|
104
|
+
case 'ask': {
|
|
105
|
+
const question = process.argv[3];
|
|
106
|
+
if (!question) throw new Error('usage: openzoo ask "<question>" [--context <id>] [--model <id>]');
|
|
107
|
+
const ci = process.argv.indexOf('--context');
|
|
108
|
+
const mi = process.argv.indexOf('--model');
|
|
109
|
+
const { PayClient } = await import('../lib/pay.js');
|
|
110
|
+
const { config } = await import('../lib/config.js');
|
|
111
|
+
const client = new PayClient();
|
|
112
|
+
const headers = { 'content-type': 'application/json' };
|
|
113
|
+
if (ci !== -1 && process.argv[ci + 1]) headers['x-hrr-context'] = process.argv[ci + 1];
|
|
114
|
+
const { response, receipt } = await client.fetch(`${config.apiBase}/v1/chat/completions`, {
|
|
115
|
+
method: 'POST',
|
|
116
|
+
headers,
|
|
117
|
+
body: JSON.stringify({
|
|
118
|
+
model: (mi !== -1 && process.argv[mi + 1]) || process.env.OPENZOO_DEFAULT_MODEL || 'deepseek/deepseek-v4-pro-0813',
|
|
119
|
+
messages: [{ role: 'user', content: question }],
|
|
120
|
+
max_tokens: Number(process.env.OPENZOO_ASK_MAX_TOKENS || 1024),
|
|
121
|
+
}),
|
|
122
|
+
});
|
|
123
|
+
if (!response.ok) throw new Error(`zoo returned HTTP ${response.status}: ${(await response.text()).slice(0, 300)}`);
|
|
124
|
+
const data = await response.json();
|
|
125
|
+
console.log(data.choices?.[0]?.message?.content ?? '(no content)');
|
|
126
|
+
if (receipt) console.error(`\n${receipt.line}`);
|
|
127
|
+
break;
|
|
128
|
+
}
|
|
80
129
|
case 'balance':
|
|
81
130
|
await (await import('../lib/info.js')).printBalance();
|
|
82
131
|
break;
|
package/lib/bindpath.js
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Chunk-and-bind a FILE or DIRECTORY from local disk.
|
|
3
|
+
*
|
|
4
|
+
* WHY THIS EXISTS: harnesses cap what they can inline. Cursor truncates every
|
|
5
|
+
* file read at 100,000 characters, so an agent asked to "consume this 8.7MB
|
|
6
|
+
* export" physically cannot put it in a request body no matter how large the
|
|
7
|
+
* zoo's context is — it never gets the bytes in the first place. The CLI and
|
|
8
|
+
* the local MCP server both run ON the machine that holds the file, so they
|
|
9
|
+
* can read it directly and hand the zoo the whole thing.
|
|
10
|
+
*
|
|
11
|
+
* A single request also cannot carry an arbitrary corpus: the network hop in
|
|
12
|
+
* front of the gateway drops bodies past ~8MB (an opaque 413 or a dead
|
|
13
|
+
* connection). So the corpus is split into parts under that ceiling and bound
|
|
14
|
+
* SEQUENTIALLY — part 1 creates the context, every later part is appended to
|
|
15
|
+
* the same context_id. The result is one context holding everything.
|
|
16
|
+
*
|
|
17
|
+
* Splitting happens on PARAGRAPH boundaries where possible. leCore's own
|
|
18
|
+
* chunker documents why ("a fact split across two chunks is retrievable from
|
|
19
|
+
* neither"), and the same reasoning applies one level up: cutting mid-sentence
|
|
20
|
+
* at an arbitrary byte offset can strand a fact across two bind calls.
|
|
21
|
+
*/
|
|
22
|
+
import fs from 'node:fs';
|
|
23
|
+
import path from 'node:path';
|
|
24
|
+
import { config } from './config.js';
|
|
25
|
+
import { corpusHash, rememberContext, lookupContext } from './contexts.js';
|
|
26
|
+
|
|
27
|
+
/** Bodies over this go in their own part. Under the ~8MB transport ceiling
|
|
28
|
+
* with room for JSON escaping, which can inflate text substantially. */
|
|
29
|
+
export const MAX_PART_BYTES = Number(process.env.OPENZOO_BIND_PART_BYTES || 4_000_000);
|
|
30
|
+
|
|
31
|
+
/** Text-ish files worth binding. Anything else is skipped rather than
|
|
32
|
+
* silently binding a binary as mojibake. */
|
|
33
|
+
const DEFAULT_EXTS = [
|
|
34
|
+
'.txt', '.md', '.json', '.jsonl', '.csv', '.tsv', '.log', '.html', '.htm', '.xml',
|
|
35
|
+
'.py', '.js', '.mjs', '.cjs', '.ts', '.tsx', '.jsx', '.rs', '.go', '.java', '.c',
|
|
36
|
+
'.h', '.cpp', '.hpp', '.rb', '.php', '.sh', '.sql', '.yaml', '.yml', '.toml', '.ini',
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
const SKIP_DIRS = new Set(['node_modules', '.git', 'target', 'dist', 'build', '.next', '__pycache__', '.venv']);
|
|
40
|
+
|
|
41
|
+
/** Every bindable file under `root` (or just `root` if it is a file). */
|
|
42
|
+
export function collectFiles(root, { exts = DEFAULT_EXTS, maxFiles = 5000 } = {}) {
|
|
43
|
+
const st = fs.statSync(root);
|
|
44
|
+
if (st.isFile()) return [root];
|
|
45
|
+
const out = [];
|
|
46
|
+
const walk = (dir) => {
|
|
47
|
+
if (out.length >= maxFiles) return;
|
|
48
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
49
|
+
if (out.length >= maxFiles) return;
|
|
50
|
+
if (entry.name.startsWith('.') && entry.name !== '.env.example') continue;
|
|
51
|
+
const p = path.join(dir, entry.name);
|
|
52
|
+
if (entry.isDirectory()) {
|
|
53
|
+
if (!SKIP_DIRS.has(entry.name)) walk(p);
|
|
54
|
+
} else if (exts.includes(path.extname(entry.name).toLowerCase())) {
|
|
55
|
+
out.push(p);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
walk(root);
|
|
60
|
+
return out.sort();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Split text into parts at or under `maxBytes`, preferring paragraph breaks
|
|
65
|
+
* and falling back to line breaks, then to a hard cut. Byte-aware, because a
|
|
66
|
+
* character count lies for non-ASCII — and chat exports are full of it.
|
|
67
|
+
*/
|
|
68
|
+
export function splitIntoParts(text, maxBytes = MAX_PART_BYTES) {
|
|
69
|
+
const parts = [];
|
|
70
|
+
let rest = text;
|
|
71
|
+
while (Buffer.byteLength(rest) > maxBytes) {
|
|
72
|
+
// Walk back from the byte ceiling to a boundary. Slice generously by
|
|
73
|
+
// chars first (bytes >= chars), then trim to fit.
|
|
74
|
+
let cut = rest.length;
|
|
75
|
+
while (Buffer.byteLength(rest.slice(0, cut)) > maxBytes) cut = Math.floor(cut * 0.9);
|
|
76
|
+
const window = rest.slice(0, cut);
|
|
77
|
+
const at = Math.max(window.lastIndexOf('\n\n'), window.lastIndexOf('\n'));
|
|
78
|
+
const end = at > cut * 0.5 ? at : cut; // only honour a boundary that is not absurdly early
|
|
79
|
+
parts.push(rest.slice(0, end));
|
|
80
|
+
rest = rest.slice(end).replace(/^\n+/, '');
|
|
81
|
+
}
|
|
82
|
+
if (rest.trim()) parts.push(rest);
|
|
83
|
+
return parts;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function postBind(payload) {
|
|
87
|
+
const r = await fetch(`${config.apiBase}/v1/hrr/bind`, {
|
|
88
|
+
method: 'POST',
|
|
89
|
+
headers: { 'content-type': 'application/json' },
|
|
90
|
+
body: JSON.stringify(payload),
|
|
91
|
+
});
|
|
92
|
+
if (r.status !== 200) throw new Error(`bind failed: HTTP ${r.status}: ${(await r.text()).slice(0, 300)}`);
|
|
93
|
+
const j = await r.json();
|
|
94
|
+
if (!j?.context_id) throw new Error('bind returned no context_id');
|
|
95
|
+
return j;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Read `target` (file or directory), split it, and bind every part into ONE
|
|
100
|
+
* context. Returns { contextId, files, parts, bytes, reused }.
|
|
101
|
+
*
|
|
102
|
+
* Binding is free, but it is not instant on a large corpus, so `onProgress`
|
|
103
|
+
* reports each part as it lands rather than going quiet for minutes.
|
|
104
|
+
*/
|
|
105
|
+
export async function bindPath(target, { exts, onProgress, force = false } = {}) {
|
|
106
|
+
const resolved = path.resolve(target);
|
|
107
|
+
if (!fs.existsSync(resolved)) throw new Error(`no such file or directory: ${resolved}`);
|
|
108
|
+
|
|
109
|
+
const files = collectFiles(resolved, exts ? { exts } : {});
|
|
110
|
+
if (!files.length) throw new Error(`nothing bindable under ${resolved} (looked for text-like files)`);
|
|
111
|
+
|
|
112
|
+
// Each file is prefixed with its path so retrieval can cite where a passage
|
|
113
|
+
// came from — a corpus of concatenated files with no provenance is much
|
|
114
|
+
// less useful to answer from.
|
|
115
|
+
const text = files
|
|
116
|
+
.map((f) => `===== ${path.relative(path.dirname(resolved), f) || path.basename(f)} =====\n${fs.readFileSync(f, 'utf8')}`)
|
|
117
|
+
.join('\n\n');
|
|
118
|
+
|
|
119
|
+
const bytes = Buffer.byteLength(text);
|
|
120
|
+
const hash = corpusHash(text);
|
|
121
|
+
if (!force) {
|
|
122
|
+
const hit = lookupContext(config.apiBase, hash);
|
|
123
|
+
if (hit) {
|
|
124
|
+
onProgress?.({ stage: 'reused', contextId: hit.context_id, bytes, files: files.length });
|
|
125
|
+
return { contextId: hit.context_id, files, parts: 0, bytes, reused: true };
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const parts = splitIntoParts(text);
|
|
130
|
+
onProgress?.({ stage: 'start', files: files.length, parts: parts.length, bytes });
|
|
131
|
+
|
|
132
|
+
let contextId = null;
|
|
133
|
+
for (let i = 0; i < parts.length; i++) {
|
|
134
|
+
// Part 1 creates the context; every later part APPENDS by passing the id
|
|
135
|
+
// back. This is what lets a corpus exceed the per-request ceiling.
|
|
136
|
+
const j = await postBind(contextId ? { corpus: parts[i], context_id: contextId } : { corpus: parts[i] });
|
|
137
|
+
contextId = j.context_id;
|
|
138
|
+
onProgress?.({ stage: 'part', index: i + 1, of: parts.length, bytes: Buffer.byteLength(parts[i]), contextId });
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
rememberContext(config.apiBase, hash, contextId);
|
|
142
|
+
return { contextId, files, parts: parts.length, bytes, reused: false };
|
|
143
|
+
}
|
package/lib/brief.js
CHANGED
|
@@ -11,9 +11,20 @@
|
|
|
11
11
|
* OPENZOO_NO_BRIEF=1 turns it off.
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
-
|
|
14
|
+
/**
|
|
15
|
+
* `selfUrl` is the base URL THIS request arrived on — the tunnel URL for a
|
|
16
|
+
* remote harness, localhost otherwise. Without it an agent writing a helper
|
|
17
|
+
* script has to guess its own endpoint, and it guesses wrong: observed in the
|
|
18
|
+
* wild, a Cursor agent hardcoded `https://openzoo.fun/v1` (a marketing site,
|
|
19
|
+
* not an API) because nothing in the conversation named the real one.
|
|
20
|
+
*/
|
|
21
|
+
export const briefFor = (selfUrl) => [
|
|
15
22
|
'You are connected through an openzoo proxy (openzoo.fun), not a stock OpenAI endpoint. What that changes:',
|
|
16
23
|
'',
|
|
24
|
+
...(selfUrl ? [
|
|
25
|
+
`0. YOUR ENDPOINT IS ${selfUrl} — use exactly this base URL in any script or curl you write, never guess one and never use openzoo.fun (that is a website, not an API). Endpoints below are relative to it: ${selfUrl}/chat/completions, ${selfUrl}/hrr/bind, ${selfUrl}/models. Do NOT insert another /v1 — this URL already ends in one.`,
|
|
26
|
+
'',
|
|
27
|
+
] : []),
|
|
17
28
|
'1. CONTEXT IS EFFECTIVELY UNBOUNDED. Any model here accepts corpora far past its own attention window: bodies over ~16KB are automatically carved and bound to a holographic (HRR) memory before the model sees them, and you then query against that. `context_length` in /v1/models is the client-usable ceiling (128M tokens), not the transformer window (that is `max_model_len`). DO NOT summarise, truncate, or "chunk to fit" a corpus to preserve context — send it whole and ask your question. One POST should stay under ~9.8M tokens (~32MiB) or the edge rejects it; for more than that, bind in several calls.',
|
|
18
29
|
'',
|
|
19
30
|
'2. THE BODY NEVER SHIPS TWICE. Put the corpus first, then a blank line, then your question. The corpus binds ONCE and every later question that reuses it ships only the question — near-free, and much faster. Re-pasting the same corpus each turn wastes real money.',
|
|
@@ -27,6 +38,9 @@ export const BRIEF = [
|
|
|
27
38
|
'4. MODEL IDS ARE FORGIVING. Ask for any model id you like; unknown ids are matched to the nearest served model, and /v1/models lists what is real (each alias row carries `served_by`).',
|
|
28
39
|
].join('\n');
|
|
29
40
|
|
|
41
|
+
/** Back-compat: the briefing with no endpoint line. */
|
|
42
|
+
export const BRIEF = briefFor(null);
|
|
43
|
+
|
|
30
44
|
/**
|
|
31
45
|
* Inject the briefing as a system message. Idempotent (never doubles up if a
|
|
32
46
|
* conversation already carries it), non-destructive (an existing system
|
|
@@ -34,13 +48,13 @@ export const BRIEF = [
|
|
|
34
48
|
* harnesses often pin behaviour in the first system turn).
|
|
35
49
|
* Returns null when nothing should change.
|
|
36
50
|
*/
|
|
37
|
-
export function injectBrief(body) {
|
|
51
|
+
export function injectBrief(body, selfUrl = null) {
|
|
38
52
|
if (process.env.OPENZOO_NO_BRIEF === '1') return null;
|
|
39
53
|
const msgs = body?.messages;
|
|
40
54
|
if (!Array.isArray(msgs) || !msgs.length) return null;
|
|
41
55
|
if (msgs.some((m) => typeof m?.content === 'string' && m.content.includes('connected through an openzoo proxy'))) return null;
|
|
42
56
|
|
|
43
|
-
const brief = { role: 'system', content:
|
|
57
|
+
const brief = { role: 'system', content: briefFor(selfUrl) };
|
|
44
58
|
const lastSystem = msgs.reduce((acc, m, i) => (m?.role === 'system' ? i : acc), -1);
|
|
45
59
|
const out = [...msgs];
|
|
46
60
|
out.splice(lastSystem + 1, 0, brief); // after any leading system block, before the user turns
|
package/lib/mcp.js
CHANGED
|
@@ -50,13 +50,27 @@ export async function startMcp() {
|
|
|
50
50
|
inputSchema: {
|
|
51
51
|
prompt: z.string().describe('The question or instruction.'),
|
|
52
52
|
corpus: z.string().optional().describe('Optional big context body (document dump, logs, book...). Placed before the prompt.'),
|
|
53
|
+
context_id: z.string().optional().describe('A context from zoo_bind / zoo_contexts. The question ships alone against the already-bound corpus — cheapest and fastest path. Do not also pass `corpus`.'),
|
|
53
54
|
model: z.string().optional().describe(`Model id from zoo_models. Default ${DEFAULT_MODEL}.`),
|
|
54
55
|
max_tokens: z.number().int().positive().optional().describe('Completion cap. Default 1024.'),
|
|
55
56
|
},
|
|
56
|
-
}, async ({ prompt, corpus, model, max_tokens }) => {
|
|
57
|
+
}, async ({ prompt, corpus, context_id, model, max_tokens }) => {
|
|
57
58
|
try {
|
|
58
59
|
const bodyBase = { model: model || DEFAULT_MODEL, max_tokens: max_tokens || 1024 };
|
|
59
60
|
let data; let receipt; let reuse = null;
|
|
61
|
+
// An explicit context is the cheap path: nothing to hash, nothing to
|
|
62
|
+
// upload, just the question plus the header.
|
|
63
|
+
if (context_id) {
|
|
64
|
+
const { response, receipt: r } = await client.fetch(`${config.apiBase}/v1/chat/completions`, {
|
|
65
|
+
method: 'POST',
|
|
66
|
+
headers: { 'content-type': 'application/json', 'x-hrr-context': context_id },
|
|
67
|
+
body: JSON.stringify({ ...bodyBase, messages: [{ role: 'user', content: prompt }] }),
|
|
68
|
+
});
|
|
69
|
+
if (!response.ok) throw new Error(`zoo returned HTTP ${response.status}: ${(await response.text()).slice(0, 300)}`);
|
|
70
|
+
data = await response.json();
|
|
71
|
+
receipt = r;
|
|
72
|
+
reuse = { contextId: context_id, corpusReused: true };
|
|
73
|
+
} else
|
|
60
74
|
// "The body never ships twice": a big corpus is bound ONCE on the zoo
|
|
61
75
|
// (sha256 manifest at ~/.openzoo/contexts.json) and later asks ship only
|
|
62
76
|
// the prompt + an X-HRR-Context header. Small corpora ride inline.
|
|
@@ -130,6 +144,38 @@ export async function startMcp() {
|
|
|
130
144
|
});
|
|
131
145
|
});
|
|
132
146
|
|
|
147
|
+
server.registerTool('zoo_bind', {
|
|
148
|
+
description:
|
|
149
|
+
'Bind a LOCAL file or directory into the zoo\'s holographic memory, then ask questions against it with zoo_ask({context_id}). '
|
|
150
|
+
+ 'USE THIS INSTEAD OF READING A LARGE FILE YOURSELF: your own file-reading tool truncates (Cursor caps every read at 100,000 characters), '
|
|
151
|
+
+ 'so a big export can never reach the model through it. This server runs on the same machine as the file — it reads the whole thing, '
|
|
152
|
+
+ 'splits it into transport-sized parts, and binds them into ONE context. Handles directories (text-like files only, skips node_modules/.git). '
|
|
153
|
+
+ 'Free — binding costs no payment. Re-binding the same content returns the existing context without re-uploading.',
|
|
154
|
+
inputSchema: {
|
|
155
|
+
path: z.string().describe('Absolute path to a file or directory on this machine.'),
|
|
156
|
+
ext: z.string().optional().describe('Comma-separated extensions to include when path is a directory, e.g. ".py,.md". Defaults to a broad text-like set.'),
|
|
157
|
+
force: z.boolean().optional().describe('Re-bind even if this exact content is already bound.'),
|
|
158
|
+
},
|
|
159
|
+
}, async ({ path: target, ext, force }) => {
|
|
160
|
+
try {
|
|
161
|
+
const { bindPath } = await import('./bindpath.js');
|
|
162
|
+
const out = await bindPath(target, {
|
|
163
|
+
exts: ext ? ext.split(',').map((e) => (e.trim().startsWith('.') ? e.trim() : `.${e.trim()}`)) : undefined,
|
|
164
|
+
force: force === true,
|
|
165
|
+
});
|
|
166
|
+
return text({
|
|
167
|
+
context_id: out.contextId,
|
|
168
|
+
bound_bytes: out.bytes,
|
|
169
|
+
files: out.files.length,
|
|
170
|
+
parts: out.parts,
|
|
171
|
+
reused: out.reused,
|
|
172
|
+
next: `Call zoo_ask with context_id="${out.contextId}" and your question. Do NOT paste the corpus into the prompt — it is already bound.`,
|
|
173
|
+
});
|
|
174
|
+
} catch (e) {
|
|
175
|
+
return text({ error: e.message });
|
|
176
|
+
}
|
|
177
|
+
});
|
|
178
|
+
|
|
133
179
|
server.registerTool('zoo_contexts', {
|
|
134
180
|
description: 'List corpora already bound to the zoo\'s holographic memory (local manifest). A listed corpus is never re-uploaded — asks against it are near-free.',
|
|
135
181
|
inputSchema: {},
|
package/lib/proxy.js
CHANGED
|
@@ -15,6 +15,11 @@ import { injectBrief } from './brief.js';
|
|
|
15
15
|
const HOP_BY_HOP = new Set([
|
|
16
16
|
'host', 'connection', 'keep-alive', 'transfer-encoding', 'upgrade',
|
|
17
17
|
'proxy-authorization', 'proxy-connection', 'te', 'trailer', 'content-length',
|
|
18
|
+
// `Expect: 100-continue` is sent by curl and most HTTP libraries once a body
|
|
19
|
+
// passes ~1KB. undici REFUSES it outright ("expect header not supported"),
|
|
20
|
+
// so forwarding it made every LARGE-body request fail while small ones
|
|
21
|
+
// worked — i.e. it broke exactly the corpus calls this proxy exists for.
|
|
22
|
+
'expect',
|
|
18
23
|
// The harness's api key (sk-openzoo or anything) is accepted and dropped:
|
|
19
24
|
// the zoo takes payment, not keys.
|
|
20
25
|
'authorization',
|
|
@@ -352,7 +357,13 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
|
|
|
352
357
|
// will read it, instead of leaving it to guess (and to chunk corpora
|
|
353
358
|
// it could bind whole). See lib/brief.js.
|
|
354
359
|
if ((req.url || '').includes('/chat/completions')) {
|
|
355
|
-
|
|
360
|
+
// Tell it the URL it actually reached us on — the public tunnel for
|
|
361
|
+
// a remote harness, localhost for a local one. An agent that has to
|
|
362
|
+
// guess its own endpoint guesses a website.
|
|
363
|
+
const selfUrl = viaTunnel && tunnelGate?.publicUrl
|
|
364
|
+
? `${tunnelGate.publicUrl}/v1`
|
|
365
|
+
: `http://localhost:${config.port}/v1`;
|
|
366
|
+
const briefed = injectBrief(parsed, selfUrl);
|
|
356
367
|
if (briefed) bodyBuf = Buffer.from(JSON.stringify(briefed));
|
|
357
368
|
}
|
|
358
369
|
} catch { /* not JSON */ }
|
|
@@ -474,7 +485,14 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
|
|
|
474
485
|
log(err.message);
|
|
475
486
|
jsonErr(res, 402, err.message);
|
|
476
487
|
} else {
|
|
477
|
-
|
|
488
|
+
// "fetch failed" alone is undiagnosable — undici hides the real
|
|
489
|
+
// network error in `cause`. Surface it (and log the stack) or every
|
|
490
|
+
// transport hiccup looks identical to a payment bug.
|
|
491
|
+
const cause = err.cause?.message || err.cause?.code || err.cause;
|
|
492
|
+
const detail = cause ? `${err.message} (${cause})` : err.message;
|
|
493
|
+
log(`proxy error: ${detail}`);
|
|
494
|
+
if (process.env.OPENZOO_DEBUG) console.error(err.stack);
|
|
495
|
+
jsonErr(res, 502, `openzoo proxy error: ${detail}`);
|
|
478
496
|
}
|
|
479
497
|
}
|
|
480
498
|
});
|
|
@@ -574,7 +592,7 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
|
|
|
574
592
|
const cap = process.env.OPENZOO_TUNNEL_MAX_USD ? Number(process.env.OPENZOO_TUNNEL_MAX_USD) : Infinity;
|
|
575
593
|
const bin = await ensureCloudflared((m) => log(m));
|
|
576
594
|
const { url, proc } = await startCloudflared(bin, config.port, log);
|
|
577
|
-
tunnelGate = { token, sessionMaxUsd: cap };
|
|
595
|
+
tunnelGate = { token, sessionMaxUsd: cap, publicUrl: url };
|
|
578
596
|
const bye = () => { try { proc.kill('SIGTERM'); } catch { /* already gone */ } };
|
|
579
597
|
process.once('SIGINT', () => { bye(); process.exit(0); });
|
|
580
598
|
process.once('SIGTERM', () => { bye(); process.exit(0); });
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "openzoo",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.0",
|
|
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",
|