openzoo 0.2.0 → 0.3.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/README.md +36 -5
- package/bin/openzoo.js +29 -0
- package/lib/contexts.js +102 -0
- package/lib/demo.js +67 -28
- package/lib/hrr.js +79 -0
- package/lib/mcp.js +32 -6
- package/lib/pay.js +2 -2
- package/lib/proxy.js +87 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -89,13 +89,42 @@ Tools:
|
|
|
89
89
|
npx openzoo demo
|
|
90
90
|
```
|
|
91
91
|
|
|
92
|
-
Builds a ~965k-token document with one planted fact, shows that buying direct refuses it (*"The input token count exceeds the maximum number of tokens allowed."* — recorded, see [benches.openzoo.fun](https://benches.openzoo.fun)),
|
|
92
|
+
Builds a ~965k-token document with one planted fact, shows that buying direct refuses it (*"The input token count exceeds the maximum number of tokens allowed."* — recorded, see [benches.openzoo.fun](https://benches.openzoo.fun)), binds the body ONCE to the zoo's holographic memory, then pays for a question that ships alone:
|
|
93
93
|
|
|
94
94
|
```
|
|
95
|
-
|
|
95
|
+
bound once in 14.8s: 3.7MB → context ctx_01KZZY8YQE…
|
|
96
|
+
quote for the ask: $0.000480 · pricing=markup (3× a tiny body — the 3.7MB corpus is not re-priced)
|
|
96
97
|
```
|
|
97
98
|
|
|
98
|
-
If the wallet is funded with USDC it pays (capped at `OPENZOO_DEMO_MAX_USD`, default $0.01) and prints the answer, the tokens the model actually read, and the receipt. If not, it prints exactly what to fund. Long waits (the
|
|
99
|
+
If the wallet is funded with USDC it pays (capped at `OPENZOO_DEMO_MAX_USD`, default $0.01) and prints the answer, the tokens the model actually read, and the receipt. If not, it prints exactly what to fund. Long waits (the one-time upload, pricing, payment, the answer) show a live progress line with stage + elapsed seconds.
|
|
100
|
+
|
|
101
|
+
**Run it twice.** The second run finds the corpus in the local manifest and never uploads it:
|
|
102
|
+
|
|
103
|
+
```
|
|
104
|
+
★ corpus already bound (context ctx_01KZZY8YQE…) — skipped the 3.7MB upload entirely.
|
|
105
|
+
...
|
|
106
|
+
★ corpus already bound — skipped 3.7MB upload; whole run took 4.3s and cost $0.000480
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
## Repeat calls are near-free (0.3.0: the body never ships twice)
|
|
110
|
+
|
|
111
|
+
The zoo keeps your corpus in leCore holographic memory; the shim keeps a manifest at `~/.openzoo/contexts.json` (chmod 600) mapping `sha256(corpus)` → the zoo's `context_id`, scoped per API base. When a request carries a corpus the manifest already knows:
|
|
112
|
+
|
|
113
|
+
- **nothing big is uploaded** — the ask ships alone with an `X-HRR-Context` header,
|
|
114
|
+
- **the 402 prices the tiny ask** (markup basis, honestly labeled `pricing=markup`), typically a few hundredths of a cent instead of re-pricing megabytes,
|
|
115
|
+
- **the answer still comes from your corpus** — the zoo recalls the relevant slices server-side.
|
|
116
|
+
|
|
117
|
+
This works in all three fronts: the **proxy** (a big pasted body in the last message is split at its last blank line, bound once, and reused on every later call — even with a different question), the **MCP** `zoo_ask` `corpus` parameter, and the **demo**. If the zoo ever forgets a context (sidecar wipe), the gateway answers 404 *before* any payment and the shim transparently re-binds once and retries — a stale manifest never fails a call.
|
|
118
|
+
|
|
119
|
+
Manage it:
|
|
120
|
+
|
|
121
|
+
```bash
|
|
122
|
+
npx openzoo contexts # list bound corpora (hash, context id, age, api base)
|
|
123
|
+
npx openzoo contexts --forget 60464d # drop one by hash prefix
|
|
124
|
+
npx openzoo contexts --forget all # drop everything
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
Opt out with `OPENZOO_NO_CONTEXT_CACHE=1` (always ship the full body); tune the threshold with `OPENZOO_CONTEXT_MIN_CHARS` (default 16384 chars).
|
|
99
128
|
|
|
100
129
|
## The wallet model
|
|
101
130
|
|
|
@@ -110,6 +139,7 @@ If the wallet is funded with USDC it pays (capped at `OPENZOO_DEMO_MAX_USD`, def
|
|
|
110
139
|
Two bases, reported per call in the 402 (`extra.pricing`):
|
|
111
140
|
- **Short prompts price at a markup** (3× provider cost) — there's nothing to spill, you're paying for passthrough.
|
|
112
141
|
- **Big bodies price at a counterfactual discount** (~10× cheaper than buying the same call direct) — the zoo's leCore memory means it never forwards your whole body upstream, and passes the savings on. Measured numbers at [benches.openzoo.fun](https://benches.openzoo.fun).
|
|
142
|
+
- **Asks against a bound corpus price on the markup basis** (3× a tiny body — the receipt says `pricing=markup`). That is not a discount trick: 3× of a few hundred tokens is normally far below even the counterfactual price of shipping the corpus, which is the whole point of binding once.
|
|
113
143
|
|
|
114
144
|
The receipt names which base you got; `extra.directUsd` / `extra.savesVsDirect` let you check the math.
|
|
115
145
|
|
|
@@ -134,12 +164,13 @@ The rail is chosen from the 402's `accepts[]` itself (Solana first). Amounts are
|
|
|
134
164
|
| `OPENZOO_WALLET` | `~/.openzoo/wallet.json` | wallet path |
|
|
135
165
|
| `OPENZOO_MAX_USD_PER_CALL` | `0.5` | refuse quotes above this |
|
|
136
166
|
| `OPENZOO_DEMO_MAX_USD` | `0.01` | demo spend cap |
|
|
167
|
+
| `OPENZOO_CONTEXT_MIN_CHARS` | `16384` | bodies bigger than this bind once + reuse |
|
|
168
|
+
| `OPENZOO_NO_CONTEXT_CACHE` | `0` | set `1` to always ship the full body |
|
|
137
169
|
| `OPENZOO_ENABLE_RH` | `0` | allow the Robinhood rail |
|
|
138
170
|
|
|
139
171
|
## What's tested
|
|
140
172
|
|
|
141
173
|
- Unit tests run against a **captured production 402 body** (`test/fixtures/live-402.json`): parsing, rail selection, and byte-level verification of the signed Solana transaction and the EVM typed-data payload.
|
|
142
|
-
- Live-verified: `/v1/models` through the proxy (200), 402 parse + quote
|
|
143
|
-
- **Not yet live-verified: an actual settled paid call** — this session's wallet was unfunded, so settlement (the facilitator accepting the signed transfer) is untested from this client. The construction matches the gateway's own documented flow byte-for-byte.
|
|
174
|
+
- Live-verified: `/v1/models` through the proxy (200), 402 parse + quote against mainnet, on-chain mint/decimals read, the internal USDC conversion (funded from plain USDC, settled on mainnet), and **a real settled paid call** — the full demo ran end-to-end against the production gateway: quote, automatic top-up from USDC, payment accepted by the facilitator, answer + receipt returned.
|
|
144
175
|
|
|
145
176
|
MIT
|
package/bin/openzoo.js
CHANGED
|
@@ -7,6 +7,9 @@ usage:
|
|
|
7
7
|
npx openzoo start the proxy on http://localhost:8402/v1
|
|
8
8
|
npx openzoo mcp stdio MCP server (tools: zoo_ask, zoo_models, zoo_wallet)
|
|
9
9
|
npx openzoo demo ~1M-token needle demo: direct refuses, the zoo answers
|
|
10
|
+
(run it twice — the second run reuses the bound corpus and is near-free)
|
|
11
|
+
npx openzoo contexts list corpora bound to the zoo (never re-uploaded)
|
|
12
|
+
npx openzoo contexts --forget <hash|all> drop manifest entries
|
|
10
13
|
npx openzoo balance wallet balance (USDC + SOL, with USD value)
|
|
11
14
|
npx openzoo address print the funding address
|
|
12
15
|
npx openzoo help this text
|
|
@@ -19,6 +22,8 @@ env:
|
|
|
19
22
|
OPENZOO_PORT (8402) OPENZOO_API_BASE (https://x402-tokens.fly.dev)
|
|
20
23
|
OPENZOO_RPC (mainnet-beta) OPENZOO_TOKEN (402 rail preference) OPENZOO_WALLET (~/.openzoo/wallet.json)
|
|
21
24
|
OPENZOO_MAX_USD_PER_CALL (0.5) OPENZOO_DEMO_MAX_USD (0.01)
|
|
25
|
+
OPENZOO_CONTEXT_MIN_CHARS (16384 — bodies bigger than this bind once + reuse)
|
|
26
|
+
OPENZOO_NO_CONTEXT_CACHE (0 — set 1 to always ship the full body)
|
|
22
27
|
OPENZOO_ENABLE_RH (0 — Robinhood Chain rail, experimental)`;
|
|
23
28
|
|
|
24
29
|
async function main() {
|
|
@@ -33,6 +38,30 @@ async function main() {
|
|
|
33
38
|
case 'demo':
|
|
34
39
|
await (await import('../lib/demo.js')).runDemo();
|
|
35
40
|
break;
|
|
41
|
+
case 'contexts': {
|
|
42
|
+
const { listContexts, forgetContexts } = await import('../lib/contexts.js');
|
|
43
|
+
const fi = process.argv.indexOf('--forget');
|
|
44
|
+
if (fi !== -1) {
|
|
45
|
+
const sel = process.argv[fi + 1];
|
|
46
|
+
if (!sel) throw new Error('usage: openzoo contexts --forget <hash-prefix|all>');
|
|
47
|
+
console.log(`forgot ${forgetContexts(sel)} context(s)`);
|
|
48
|
+
break;
|
|
49
|
+
}
|
|
50
|
+
const all = listContexts();
|
|
51
|
+
if (!all.length) {
|
|
52
|
+
console.log('no bound corpora yet — run `npx openzoo demo` or ask with a big body.');
|
|
53
|
+
break;
|
|
54
|
+
}
|
|
55
|
+
const age = (iso) => {
|
|
56
|
+
const s = Math.max(0, (Date.now() - Date.parse(iso)) / 1000);
|
|
57
|
+
return s < 3600 ? `${Math.round(s / 60)}m` : s < 86400 ? `${(s / 3600).toFixed(1)}h` : `${(s / 86400).toFixed(1)}d`;
|
|
58
|
+
};
|
|
59
|
+
for (const c of all) {
|
|
60
|
+
console.log(`${c.hash.slice(0, 12)} ${c.context_id} bound ${age(c.boundAt)} ago ${c.apiBase}`);
|
|
61
|
+
}
|
|
62
|
+
console.log('\nthese corpora never ship again — asks against them send only the question + X-HRR-Context.');
|
|
63
|
+
break;
|
|
64
|
+
}
|
|
36
65
|
case 'balance':
|
|
37
66
|
await (await import('../lib/info.js')).printBalance();
|
|
38
67
|
break;
|
package/lib/contexts.js
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Context manifest — "the body never ships twice."
|
|
3
|
+
*
|
|
4
|
+
* ~/.openzoo/contexts.json (chmod 600) maps sha256(normalized corpus) →
|
|
5
|
+
* { context_id, boundAt } per API base. A hit means the corpus is already
|
|
6
|
+
* bound on the zoo's holographic sidecar, so an ask ships only the question
|
|
7
|
+
* plus an X-HRR-Context header instead of re-uploading megabytes.
|
|
8
|
+
*
|
|
9
|
+
* Scoped per API base: the same corpus bound on a different zoo is a
|
|
10
|
+
* different context id, and pointing OPENZOO_API_BASE elsewhere must never
|
|
11
|
+
* replay ids the new gateway has never seen.
|
|
12
|
+
*/
|
|
13
|
+
import { createHash } from 'node:crypto';
|
|
14
|
+
import { readFileSync, writeFileSync, mkdirSync, chmodSync, renameSync } from 'node:fs';
|
|
15
|
+
import os from 'node:os';
|
|
16
|
+
import path from 'node:path';
|
|
17
|
+
|
|
18
|
+
const FILE = path.join(os.homedir(), '.openzoo', 'contexts.json');
|
|
19
|
+
|
|
20
|
+
/** sha256 of the normalized corpus text. Normalization (CRLF→LF + trim) keeps
|
|
21
|
+
* the hash stable across OSes and trailing-whitespace edits — the corpus the
|
|
22
|
+
* sidecar chunks is semantically identical either way. */
|
|
23
|
+
export function corpusHash(text) {
|
|
24
|
+
return createHash('sha256').update(String(text).replace(/\r\n/g, '\n').trim()).digest('hex');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function load() {
|
|
28
|
+
try {
|
|
29
|
+
const j = JSON.parse(readFileSync(FILE, 'utf8'));
|
|
30
|
+
return j && typeof j === 'object' ? j : {};
|
|
31
|
+
} catch {
|
|
32
|
+
return {};
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function save(map) {
|
|
37
|
+
mkdirSync(path.dirname(FILE), { recursive: true });
|
|
38
|
+
// write-then-rename so a crash mid-write never truncates the manifest
|
|
39
|
+
const tmp = FILE + '.tmp';
|
|
40
|
+
writeFileSync(tmp, JSON.stringify(map, null, 2));
|
|
41
|
+
chmodSync(tmp, 0o600);
|
|
42
|
+
renameSync(tmp, FILE);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** manifest entry for (apiBase, hash) or null. */
|
|
46
|
+
export function lookupContext(apiBase, hash) {
|
|
47
|
+
const map = load();
|
|
48
|
+
return map[apiBase]?.[hash] ?? null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function rememberContext(apiBase, hash, contextId) {
|
|
52
|
+
const map = load();
|
|
53
|
+
map[apiBase] = map[apiBase] || {};
|
|
54
|
+
map[apiBase][hash] = { context_id: contextId, boundAt: new Date().toISOString() };
|
|
55
|
+
save(map);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function forgetContext(apiBase, hash) {
|
|
59
|
+
const map = load();
|
|
60
|
+
if (map[apiBase]?.[hash]) {
|
|
61
|
+
delete map[apiBase][hash];
|
|
62
|
+
if (!Object.keys(map[apiBase]).length) delete map[apiBase];
|
|
63
|
+
save(map);
|
|
64
|
+
return 1;
|
|
65
|
+
}
|
|
66
|
+
return 0;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Flat list of every entry: { apiBase, hash, context_id, boundAt }. */
|
|
70
|
+
export function listContexts() {
|
|
71
|
+
const map = load();
|
|
72
|
+
const out = [];
|
|
73
|
+
for (const [apiBase, entries] of Object.entries(map)) {
|
|
74
|
+
for (const [hash, e] of Object.entries(entries)) {
|
|
75
|
+
out.push({ apiBase, hash, context_id: e.context_id, boundAt: e.boundAt });
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
out.sort((a, b) => String(b.boundAt).localeCompare(String(a.boundAt)));
|
|
79
|
+
return out;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Forget by hash prefix (any api base) or 'all'. Returns removed count. */
|
|
83
|
+
export function forgetContexts(selector) {
|
|
84
|
+
const map = load();
|
|
85
|
+
let removed = 0;
|
|
86
|
+
if (selector === 'all') {
|
|
87
|
+
for (const entries of Object.values(map)) removed += Object.keys(entries).length;
|
|
88
|
+
save({});
|
|
89
|
+
return removed;
|
|
90
|
+
}
|
|
91
|
+
for (const [apiBase, entries] of Object.entries(map)) {
|
|
92
|
+
for (const hash of Object.keys(entries)) {
|
|
93
|
+
if (hash.startsWith(selector)) {
|
|
94
|
+
delete entries[hash];
|
|
95
|
+
removed += 1;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
if (!Object.keys(entries).length) delete map[apiBase];
|
|
99
|
+
}
|
|
100
|
+
if (removed) save(map);
|
|
101
|
+
return removed;
|
|
102
|
+
}
|
package/lib/demo.js
CHANGED
|
@@ -3,6 +3,8 @@ import { PayClient, UnderfundedError } from './pay.js';
|
|
|
3
3
|
import { parse402, pickAccept, tokenBalance } from './x402.js';
|
|
4
4
|
import { startSpinner, postWithUploadSignal } from './spinner.js';
|
|
5
5
|
import { resolvePool, poolState, depositForShares } from './wrap.js';
|
|
6
|
+
import { bindCorpus, askWithContext } from './hrr.js';
|
|
7
|
+
import { forgetContext } from './contexts.js';
|
|
6
8
|
|
|
7
9
|
const DEMO_MODEL = process.env.OPENZOO_DEMO_MODEL || 'nvidia/nemotron-3.5-lightning';
|
|
8
10
|
const NEEDLE = 'The zebra vault code is 7-ALPHA-9243.';
|
|
@@ -29,14 +31,16 @@ function bigBody(targetTokens) {
|
|
|
29
31
|
return parts.join('\n\n');
|
|
30
32
|
}
|
|
31
33
|
|
|
32
|
-
async function quoteFor(bodyObj) {
|
|
34
|
+
async function quoteFor(bodyObj, headers = {}) {
|
|
33
35
|
const body = JSON.stringify(bodyObj);
|
|
34
36
|
const mb = (body.length / 1048576).toFixed(1);
|
|
35
37
|
const approxTokens = Math.round(body.length / 4 / 1000);
|
|
36
|
-
const
|
|
38
|
+
const big = body.length > 262144;
|
|
39
|
+
const spin = startSpinner(big ? `uploading ${mb}MB body` : 'asking the zoo for a live quote');
|
|
37
40
|
try {
|
|
38
41
|
const r = await postWithUploadSignal(`${config.apiBase}/v1/chat/completions`, body, {
|
|
39
|
-
|
|
42
|
+
headers,
|
|
43
|
+
onUploaded: () => { if (big) spin.update(`zoo pricing ~${approxTokens}k tokens`); },
|
|
40
44
|
});
|
|
41
45
|
if (r.status !== 402) throw new Error(`expected 402 quote, got HTTP ${r.status}: ${(await r.text()).slice(0, 300)}`);
|
|
42
46
|
return parse402(await r.json());
|
|
@@ -71,64 +75,93 @@ export async function runDemo() {
|
|
|
71
75
|
console.log(' (recorded against the same model class, 2026-08-13 — benches.openzoo.fun)');
|
|
72
76
|
console.log('');
|
|
73
77
|
|
|
74
|
-
console.log('2) same body through the zoo —
|
|
78
|
+
console.log('2) same body through the zoo — but the body never ships twice (0.3.0):');
|
|
75
79
|
const doc = bigBody(tokens);
|
|
76
|
-
const
|
|
80
|
+
const docMb = (Buffer.byteLength(doc) / 1048576).toFixed(1);
|
|
81
|
+
const askBody = {
|
|
77
82
|
model: DEMO_MODEL,
|
|
78
|
-
messages: [{ role: 'user', content:
|
|
79
|
-
|
|
83
|
+
messages: [{ role: 'user', content: QUESTION }],
|
|
84
|
+
// Reasoning models burn budget on thinking before visible output — 32 was
|
|
85
|
+
// returning empty content with finish_reason=length on nemotron.
|
|
86
|
+
max_tokens: 512,
|
|
80
87
|
temperature: 0,
|
|
81
88
|
};
|
|
82
|
-
|
|
89
|
+
|
|
90
|
+
const tRun = Date.now();
|
|
91
|
+
let bindSpin = null;
|
|
92
|
+
const bindStages = {
|
|
93
|
+
onStage: (stage, info) => {
|
|
94
|
+
if (stage === 'binding') bindSpin = startSpinner(`uploading + binding ${docMb}MB corpus (one-time)`);
|
|
95
|
+
if (stage === 'bound-uploading-done') bindSpin?.update('zoo chunking + indexing the corpus');
|
|
96
|
+
},
|
|
97
|
+
};
|
|
98
|
+
let bind = await bindCorpus(doc, bindStages);
|
|
99
|
+
bindSpin?.stop();
|
|
100
|
+
if (bind.reused) {
|
|
101
|
+
console.log(` ★ corpus already bound (context ${bind.contextId}) — skipped the ${docMb}MB upload entirely.`);
|
|
102
|
+
} else {
|
|
103
|
+
console.log(` bound once in ${((Date.now() - tRun) / 1000).toFixed(1)}s: ${docMb}MB → context ${bind.contextId}.`);
|
|
104
|
+
console.log(' re-run `npx openzoo demo` — this upload never happens again.');
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
let quote;
|
|
108
|
+
try {
|
|
109
|
+
quote = await quoteFor(askBody, { 'X-HRR-Context': bind.contextId });
|
|
110
|
+
} catch (err) {
|
|
111
|
+
// Sidecar wiped since we bound (stale manifest): 404 pre-402, nothing
|
|
112
|
+
// paid. Never fail on it — re-bind once and re-quote.
|
|
113
|
+
if (!/context_not_found/.test(err.message)) throw err;
|
|
114
|
+
console.log(' bound context is gone on the zoo — re-binding once...');
|
|
115
|
+
forgetContext(config.apiBase, bind.hash);
|
|
116
|
+
bind = await bindCorpus(doc, { ...bindStages, force: true });
|
|
117
|
+
bindSpin?.stop();
|
|
118
|
+
console.log(` re-bound: ${docMb}MB → context ${bind.contextId}.`);
|
|
119
|
+
quote = await quoteFor(askBody, { 'X-HRR-Context': bind.contextId });
|
|
120
|
+
}
|
|
83
121
|
const accept = pickAccept(quote, config.token);
|
|
84
122
|
const x = accept.extra || {};
|
|
85
|
-
console.log(` quote: $${Number(x.billedUsd).toFixed(6)} · pricing=${x.pricing}`
|
|
86
|
-
+ (x.directUsd != null ? ` · direct would be $${Number(x.directUsd).toFixed(6)} · savesVsDirect=${Number(x.savesVsDirect).toFixed(1)}×` : ''));
|
|
123
|
+
console.log(` quote for the ask: $${Number(x.billedUsd).toFixed(6)} · pricing=${x.pricing} (3× a tiny body — the ${docMb}MB corpus is not re-priced)`);
|
|
87
124
|
console.log('');
|
|
88
125
|
|
|
89
|
-
// Decide what we can actually pay for under the demo cap.
|
|
90
|
-
let payBody = bodyObj;
|
|
91
|
-
let payAccept = accept;
|
|
92
126
|
if (Number(x.billedUsd) > config.demoMaxUsd) {
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
console.log(' set OPENZOO_DEMO_MAX_USD higher to pay for the whole thing)');
|
|
96
|
-
const doc2 = bigBody(fitTokens);
|
|
97
|
-
payBody = { ...bodyObj, messages: [{ role: 'user', content: `${doc2}\n\n${QUESTION}` }] };
|
|
98
|
-
const q2 = await quoteFor(payBody);
|
|
99
|
-
payAccept = pickAccept(q2, config.token);
|
|
127
|
+
console.log(` quote is over the demo cap $${config.demoMaxUsd} — set OPENZOO_DEMO_MAX_USD higher to pay it.`);
|
|
128
|
+
return;
|
|
100
129
|
}
|
|
101
130
|
|
|
102
|
-
const cov = await coverage(connection, keypair.publicKey,
|
|
131
|
+
const cov = await coverage(connection, keypair.publicKey, accept);
|
|
103
132
|
if (!cov.covered) {
|
|
104
133
|
console.log('3) wallet not funded yet, so stopping before payment.');
|
|
105
|
-
console.log(` quote : ≈ $${Number(
|
|
134
|
+
console.log(` quote : ≈ $${Number(x.billedUsd).toFixed(6)}`);
|
|
106
135
|
console.log(` wallet: $${(cov.usdcUi ?? 0).toFixed(2)} USDC`);
|
|
107
136
|
console.log(` fund : send a few cents of USDC to ${pub}`);
|
|
108
137
|
console.log(' then re-run `npx openzoo demo`.');
|
|
109
138
|
return;
|
|
110
139
|
}
|
|
111
140
|
|
|
112
|
-
console.log('3) paying and asking...');
|
|
113
|
-
const
|
|
114
|
-
const spin = startSpinner(`re-sending ${paidMb}MB body for the paid call`);
|
|
141
|
+
console.log('3) paying and asking (the question rides alone — plus one header)...');
|
|
142
|
+
const spin = startSpinner('sending the ask with X-HRR-Context');
|
|
115
143
|
const t0 = Date.now();
|
|
116
|
-
let
|
|
144
|
+
let out;
|
|
117
145
|
try {
|
|
118
|
-
|
|
146
|
+
out = await askWithContext(client, doc, askBody, {
|
|
119
147
|
onStage: (stage) => {
|
|
148
|
+
if (stage === 'reused') spin.update('reusing bound corpus');
|
|
149
|
+
if (stage === 'rebinding') spin.update('context was wiped upstream — re-binding once');
|
|
150
|
+
if (stage === 'binding') spin.update(`re-uploading ${docMb}MB corpus`);
|
|
120
151
|
if (stage === 'quoted') spin.update('building the payment');
|
|
121
152
|
if (stage === 'funding') spin.update('preparing funds (one-time)');
|
|
122
153
|
if (stage === 'paying') spin.update('paying + waiting for the answer');
|
|
123
154
|
},
|
|
124
|
-
})
|
|
155
|
+
});
|
|
125
156
|
} catch (err) {
|
|
126
157
|
spin.stop();
|
|
127
158
|
if (err instanceof UnderfundedError) { console.log(` ${err.message}`); return; }
|
|
128
159
|
throw err;
|
|
129
160
|
}
|
|
130
161
|
spin.stop();
|
|
162
|
+
const { data, receipt } = out;
|
|
131
163
|
const secs = ((Date.now() - t0) / 1000).toFixed(1);
|
|
164
|
+
const totalSecs = ((Date.now() - tRun) / 1000).toFixed(1);
|
|
132
165
|
const answer = data?.choices?.[0]?.message?.content ?? '';
|
|
133
166
|
const usage = data?.usage || {};
|
|
134
167
|
console.log(` answer (${secs}s): ${answer.trim()}`);
|
|
@@ -137,4 +170,10 @@ export async function runDemo() {
|
|
|
137
170
|
console.log(` tokens the model actually read: ${usage.gpu_tokens ?? usage.prompt_tokens}${usage.spill_tokens ? ` (spilled: ${usage.spill_tokens})` : ''}`);
|
|
138
171
|
}
|
|
139
172
|
if (receipt) console.log(` ${receipt.line}`);
|
|
173
|
+
if (bind.reused) {
|
|
174
|
+
const paidUsd = Number(receipt?.billedUsd);
|
|
175
|
+
console.log('');
|
|
176
|
+
console.log(` ★ corpus already bound — skipped ${docMb}MB upload; whole run took ${totalSecs}s`
|
|
177
|
+
+ (Number.isFinite(paidUsd) ? ` and cost $${paidUsd.toFixed(6)}` : ''));
|
|
178
|
+
}
|
|
140
179
|
}
|
package/lib/hrr.js
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client side of "the body never ships twice."
|
|
3
|
+
*
|
|
4
|
+
* bindCorpus: hash the corpus; on a manifest hit return the recorded
|
|
5
|
+
* context_id without touching the network. On a miss, POST the corpus ONCE to
|
|
6
|
+
* the zoo's free bind endpoint (the gateway chunks it with leCore server-side)
|
|
7
|
+
* and record the id.
|
|
8
|
+
*
|
|
9
|
+
* askWithContext: send the SMALL ask with X-HRR-Context. If the gateway says
|
|
10
|
+
* the context is gone (sidecar wiped — HTTP 404 code context_not_found,
|
|
11
|
+
* returned BEFORE the 402 so nothing was paid), forget the stale entry,
|
|
12
|
+
* re-bind once, and retry. A stale manifest must never fail a call.
|
|
13
|
+
*/
|
|
14
|
+
import { config } from './config.js';
|
|
15
|
+
import { corpusHash, lookupContext, rememberContext, forgetContext } from './contexts.js';
|
|
16
|
+
import { postWithUploadSignal } from './spinner.js';
|
|
17
|
+
|
|
18
|
+
/** Corpora under this many chars ride inline — binding has a round-trip cost
|
|
19
|
+
* and the reuse win only matters when the body is actually big. */
|
|
20
|
+
export const BIND_MIN_CHARS = Number(process.env.OPENZOO_CONTEXT_MIN_CHARS || 16384);
|
|
21
|
+
|
|
22
|
+
export const contextCacheDisabled = () => process.env.OPENZOO_NO_CONTEXT_CACHE === '1';
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Ensure `corpus` is bound on the zoo. Returns
|
|
26
|
+
* { contextId, hash, reused, bytes } — reused=true means zero bytes shipped.
|
|
27
|
+
*/
|
|
28
|
+
export async function bindCorpus(corpus, { onStage, force = false } = {}) {
|
|
29
|
+
const hash = corpusHash(corpus);
|
|
30
|
+
const bytes = Buffer.byteLength(corpus);
|
|
31
|
+
if (!force) {
|
|
32
|
+
const hit = lookupContext(config.apiBase, hash);
|
|
33
|
+
if (hit) {
|
|
34
|
+
onStage?.('reused', { contextId: hit.context_id, boundAt: hit.boundAt, bytes });
|
|
35
|
+
return { contextId: hit.context_id, hash, reused: true, bytes };
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
onStage?.('binding', { bytes });
|
|
39
|
+
const r = await postWithUploadSignal(`${config.apiBase}/v1/hrr/bind`, JSON.stringify({ corpus }), {
|
|
40
|
+
onUploaded: () => onStage?.('bound-uploading-done', { bytes }),
|
|
41
|
+
});
|
|
42
|
+
if (r.status !== 200) {
|
|
43
|
+
throw new Error(`bind failed: HTTP ${r.status}: ${(await r.text()).slice(0, 300)}`);
|
|
44
|
+
}
|
|
45
|
+
const j = await r.json();
|
|
46
|
+
if (!j?.context_id) throw new Error('bind returned no context_id');
|
|
47
|
+
rememberContext(config.apiBase, hash, j.context_id);
|
|
48
|
+
return { contextId: j.context_id, hash, reused: false, bytes, bound: j.bound };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const isContextGone = (status, text) =>
|
|
52
|
+
status === 404 && /context_not_found/.test(text);
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* One paid ask against a bound corpus. bodyObj should already be SMALL (the
|
|
56
|
+
* question, not the corpus). Retries exactly once through a fresh bind when
|
|
57
|
+
* the context is gone. Returns { data, receipt, contextId, reused }.
|
|
58
|
+
*/
|
|
59
|
+
export async function askWithContext(client, corpus, bodyObj, { onStage } = {}) {
|
|
60
|
+
let bind = await bindCorpus(corpus, { onStage });
|
|
61
|
+
for (let attempt = 0; ; attempt++) {
|
|
62
|
+
const { response, receipt } = await client.fetch(`${config.apiBase}/v1/chat/completions`, {
|
|
63
|
+
method: 'POST',
|
|
64
|
+
headers: { 'content-type': 'application/json', 'X-HRR-Context': bind.contextId },
|
|
65
|
+
body: JSON.stringify(bodyObj),
|
|
66
|
+
}, { onStage });
|
|
67
|
+
if (response.ok) {
|
|
68
|
+
return { data: await response.json(), receipt, contextId: bind.contextId, reused: bind.reused };
|
|
69
|
+
}
|
|
70
|
+
const text = (await response.text()).slice(0, 500);
|
|
71
|
+
if (attempt === 0 && isContextGone(response.status, text)) {
|
|
72
|
+
onStage?.('rebinding', { contextId: bind.contextId });
|
|
73
|
+
forgetContext(config.apiBase, bind.hash);
|
|
74
|
+
bind = await bindCorpus(corpus, { onStage, force: true });
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
throw new Error(`zoo returned HTTP ${response.status}: ${text}`);
|
|
78
|
+
}
|
|
79
|
+
}
|
package/lib/mcp.js
CHANGED
|
@@ -5,6 +5,8 @@ import { z } from 'zod';
|
|
|
5
5
|
import { config } from './config.js';
|
|
6
6
|
import { PayClient, QuoteTooHighError, UnderfundedError } from './pay.js';
|
|
7
7
|
import { tokenBalance } from './x402.js';
|
|
8
|
+
import { askWithContext, contextCacheDisabled, BIND_MIN_CHARS } from './hrr.js';
|
|
9
|
+
import { listContexts } from './contexts.js';
|
|
8
10
|
|
|
9
11
|
const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
|
|
10
12
|
const USDC = 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v';
|
|
@@ -33,13 +35,26 @@ export async function startMcp() {
|
|
|
33
35
|
max_tokens: z.number().int().positive().optional().describe('Completion cap. Default 1024.'),
|
|
34
36
|
},
|
|
35
37
|
}, async ({ prompt, corpus, model, max_tokens }) => {
|
|
36
|
-
const content = corpus ? `${corpus}\n\n${prompt}` : prompt;
|
|
37
38
|
try {
|
|
38
|
-
const {
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
39
|
+
const bodyBase = { model: model || DEFAULT_MODEL, max_tokens: max_tokens || 1024 };
|
|
40
|
+
let data; let receipt; let reuse = null;
|
|
41
|
+
// "The body never ships twice": a big corpus is bound ONCE on the zoo
|
|
42
|
+
// (sha256 manifest at ~/.openzoo/contexts.json) and later asks ship only
|
|
43
|
+
// the prompt + an X-HRR-Context header. Small corpora ride inline.
|
|
44
|
+
if (corpus && corpus.length > BIND_MIN_CHARS && !contextCacheDisabled()) {
|
|
45
|
+
const out = await askWithContext(client, corpus, {
|
|
46
|
+
...bodyBase,
|
|
47
|
+
messages: [{ role: 'user', content: prompt }],
|
|
48
|
+
});
|
|
49
|
+
({ data, receipt } = out);
|
|
50
|
+
reuse = { contextId: out.contextId, corpusReused: out.reused };
|
|
51
|
+
} else {
|
|
52
|
+
const content = corpus ? `${corpus}\n\n${prompt}` : prompt;
|
|
53
|
+
({ data, receipt } = await client.chat({
|
|
54
|
+
...bodyBase,
|
|
55
|
+
messages: [{ role: 'user', content }],
|
|
56
|
+
}));
|
|
57
|
+
}
|
|
43
58
|
const usage = data?.usage || {};
|
|
44
59
|
return text({
|
|
45
60
|
answer: data?.choices?.[0]?.message?.content ?? '',
|
|
@@ -52,6 +67,7 @@ export async function startMcp() {
|
|
|
52
67
|
} : 'free (no 402 issued)',
|
|
53
68
|
tokensRead: usage.gpu_tokens ?? usage.prompt_tokens ?? null,
|
|
54
69
|
spillTokens: usage.spill_tokens ?? null,
|
|
70
|
+
...(reuse ? { context: reuse } : {}),
|
|
55
71
|
});
|
|
56
72
|
} catch (err) {
|
|
57
73
|
if (err instanceof UnderfundedError || err instanceof QuoteTooHighError) {
|
|
@@ -85,6 +101,16 @@ export async function startMcp() {
|
|
|
85
101
|
});
|
|
86
102
|
});
|
|
87
103
|
|
|
104
|
+
server.registerTool('zoo_contexts', {
|
|
105
|
+
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.',
|
|
106
|
+
inputSchema: {},
|
|
107
|
+
}, async () => text({
|
|
108
|
+
manifest: '~/.openzoo/contexts.json',
|
|
109
|
+
contexts: listContexts().map((c) => ({
|
|
110
|
+
hash: c.hash.slice(0, 12), context_id: c.context_id, boundAt: c.boundAt, apiBase: c.apiBase,
|
|
111
|
+
})),
|
|
112
|
+
}));
|
|
113
|
+
|
|
88
114
|
const transport = new StdioServerTransport();
|
|
89
115
|
await server.connect(transport);
|
|
90
116
|
console.error(`openzoo mcp on stdio — wallet ${client.address} — zoo ${config.apiBase}`);
|
package/lib/pay.js
CHANGED
|
@@ -169,10 +169,10 @@ export class PayClient {
|
|
|
169
169
|
}
|
|
170
170
|
|
|
171
171
|
/** POST a chat completion, paying as needed. Returns { data, receipt }. */
|
|
172
|
-
async chat(bodyObj, { onStage } = {}) {
|
|
172
|
+
async chat(bodyObj, { onStage, headers } = {}) {
|
|
173
173
|
const { response, receipt } = await this.fetch(`${config.apiBase}/v1/chat/completions`, {
|
|
174
174
|
method: 'POST',
|
|
175
|
-
headers: { 'content-type': 'application/json' },
|
|
175
|
+
headers: { 'content-type': 'application/json', ...(headers || {}) },
|
|
176
176
|
body: JSON.stringify(bodyObj),
|
|
177
177
|
}, { onStage });
|
|
178
178
|
if (!response.ok) {
|
package/lib/proxy.js
CHANGED
|
@@ -3,6 +3,8 @@ import { Readable } from 'node:stream';
|
|
|
3
3
|
import { config } from './config.js';
|
|
4
4
|
import { PayClient, QuoteTooHighError, UnderfundedError } from './pay.js';
|
|
5
5
|
import { tokenBalance } from './x402.js';
|
|
6
|
+
import { bindCorpus, contextCacheDisabled, BIND_MIN_CHARS } from './hrr.js';
|
|
7
|
+
import { forgetContext } from './contexts.js';
|
|
6
8
|
|
|
7
9
|
const HOP_BY_HOP = new Set([
|
|
8
10
|
'host', 'connection', 'keep-alive', 'transfer-encoding', 'upgrade',
|
|
@@ -48,6 +50,58 @@ function jsonErr(res, status, message, extraFields = {}) {
|
|
|
48
50
|
res.end(JSON.stringify({ error: { message }, ...extraFields }));
|
|
49
51
|
}
|
|
50
52
|
|
|
53
|
+
const mb = (n) => (n / 1048576).toFixed(1);
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* "The body never ships twice" at the proxy. A chat body whose LAST message
|
|
57
|
+
* carries a huge pasted corpus gets split at its last blank line — corpus vs
|
|
58
|
+
* ask — so the corpus can be bound ONCE on the zoo and every later call ships
|
|
59
|
+
* only the ask plus X-HRR-Context. The split point is deterministic, which is
|
|
60
|
+
* what makes the sha256 manifest hit on run 2 even when the question changed.
|
|
61
|
+
*
|
|
62
|
+
* Conservative on purpose: only a single big STRING content on the final
|
|
63
|
+
* message, only when a blank-line boundary exists, and any failure falls back
|
|
64
|
+
* to sending the original body untouched — caching must never break a call.
|
|
65
|
+
* Returns null (send as-is) or { body, contextId, hash, corpus, reused, savedBytes }.
|
|
66
|
+
*/
|
|
67
|
+
async function maybeCacheCorpus(req, bodyBuf, log) {
|
|
68
|
+
if (contextCacheDisabled()) return null;
|
|
69
|
+
if (req.method !== 'POST' || !(req.url || '').includes('/chat/completions')) return null;
|
|
70
|
+
if (req.headers['x-hrr-context']) return null; // harness manages its own context
|
|
71
|
+
if (bodyBuf.length <= BIND_MIN_CHARS) return null;
|
|
72
|
+
let body;
|
|
73
|
+
try { body = JSON.parse(bodyBuf.toString('utf8')); } catch { return null; }
|
|
74
|
+
const msgs = Array.isArray(body?.messages) ? body.messages : null;
|
|
75
|
+
if (!msgs?.length) return null;
|
|
76
|
+
const last = msgs[msgs.length - 1];
|
|
77
|
+
if (typeof last?.content !== 'string' || last.content.length <= BIND_MIN_CHARS) return null;
|
|
78
|
+
const cut = last.content.lastIndexOf('\n\n');
|
|
79
|
+
if (cut < BIND_MIN_CHARS) return null;
|
|
80
|
+
const corpus = last.content.slice(0, cut);
|
|
81
|
+
const ask = last.content.slice(cut + 2).trim();
|
|
82
|
+
if (!ask || ask.length > 8000) return null;
|
|
83
|
+
|
|
84
|
+
const bind = await bindCorpus(corpus, {
|
|
85
|
+
onStage: (stage, info) => {
|
|
86
|
+
if (stage === 'binding') log(`binding ${mb(info.bytes)}MB corpus to holographic memory (one-time)...`);
|
|
87
|
+
},
|
|
88
|
+
});
|
|
89
|
+
if (bind.reused) {
|
|
90
|
+
log(`corpus already bound (${bind.hash.slice(0, 12)}… → ${bind.contextId}) — skipped ${mb(bind.bytes)}MB upload`);
|
|
91
|
+
} else {
|
|
92
|
+
log(`corpus bound once (${mb(bind.bytes)}MB → ${bind.contextId}) — repeats of this body are near-free`);
|
|
93
|
+
}
|
|
94
|
+
const rewritten = { ...body, messages: [...msgs.slice(0, -1), { ...last, content: ask }] };
|
|
95
|
+
return {
|
|
96
|
+
body: Buffer.from(JSON.stringify(rewritten)),
|
|
97
|
+
contextId: bind.contextId,
|
|
98
|
+
hash: bind.hash,
|
|
99
|
+
corpus,
|
|
100
|
+
reused: bind.reused,
|
|
101
|
+
savedBytes: bind.bytes,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
51
105
|
export async function startProxy({ silent = false } = {}) {
|
|
52
106
|
const client = new PayClient();
|
|
53
107
|
const log = silent ? () => {} : (...a) => console.log(...a);
|
|
@@ -65,7 +119,39 @@ export async function startProxy({ silent = false } = {}) {
|
|
|
65
119
|
if (req.method !== 'GET' && req.method !== 'HEAD') init.body = bodyBuf;
|
|
66
120
|
|
|
67
121
|
try {
|
|
68
|
-
|
|
122
|
+
let cached = null;
|
|
123
|
+
try {
|
|
124
|
+
cached = await maybeCacheCorpus(req, bodyBuf, log);
|
|
125
|
+
} catch (err) {
|
|
126
|
+
log(`context cache skipped for this call: ${err.message}`);
|
|
127
|
+
}
|
|
128
|
+
const send = (buf, ctxId) => client.fetch(url, {
|
|
129
|
+
...init,
|
|
130
|
+
body: buf,
|
|
131
|
+
headers: ctxId ? { ...init.headers, 'x-hrr-context': ctxId } : init.headers,
|
|
132
|
+
});
|
|
133
|
+
let result;
|
|
134
|
+
if (cached) {
|
|
135
|
+
result = await send(cached.body, cached.contextId);
|
|
136
|
+
// Sidecar wiped between runs: the gateway 404s BEFORE the 402 (nothing
|
|
137
|
+
// paid). Never fail on a stale manifest — re-bind once and retry.
|
|
138
|
+
if (result.response.status === 404) {
|
|
139
|
+
const text = await result.response.text();
|
|
140
|
+
if (/context_not_found/.test(text)) {
|
|
141
|
+
log('bound context is gone on the zoo — re-binding once...');
|
|
142
|
+
forgetContext(config.apiBase, cached.hash);
|
|
143
|
+
const rebound = await bindCorpus(cached.corpus, { force: true });
|
|
144
|
+
result = await send(cached.body, rebound.contextId);
|
|
145
|
+
} else {
|
|
146
|
+
res.writeHead(404, { 'content-type': 'application/json' });
|
|
147
|
+
res.end(text);
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
} else {
|
|
152
|
+
result = await client.fetch(url, init);
|
|
153
|
+
}
|
|
154
|
+
const { response, paid, receipt } = result;
|
|
69
155
|
if (paid && receipt) log(receipt.ok ? receipt.line : `paid retry -> HTTP ${receipt.status}`);
|
|
70
156
|
await relay(res, response);
|
|
71
157
|
} catch (err) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "openzoo",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.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 rail live; Base/Robinhood rails experimental.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|