openzoo 0.35.1 → 0.35.3
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/gui.html +258 -0
- package/lib/proxy.js +41 -3
- package/package.json +1 -1
package/lib/gui.html
ADDED
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en" data-page="openzoo-gui">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" data-meta="charset">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>openzoo</title>
|
|
7
|
+
<style data-component="styles">
|
|
8
|
+
:root {
|
|
9
|
+
--paper: #faf6ee; --card: #ffffff; --ink: #22271f; --muted: #7a806f;
|
|
10
|
+
--leaf: #3e7c4f; --leaf-soft: #e3efe0; --line: #e6e0d2; --accent-warm: #f3e8d2;
|
|
11
|
+
}
|
|
12
|
+
* { box-sizing: border-box; }
|
|
13
|
+
body { margin: 0; background: var(--paper); color: var(--ink);
|
|
14
|
+
font: 15px/1.55 ui-rounded, "SF Pro Rounded", -apple-system, "Segoe UI", sans-serif;
|
|
15
|
+
display: flex; flex-direction: column; height: 100vh; }
|
|
16
|
+
header { display: flex; align-items: center; gap: 10px; padding: 12px 18px;
|
|
17
|
+
border-bottom: 1px solid var(--line); background: var(--card); }
|
|
18
|
+
header h1 { font-size: 17px; margin: 0; font-weight: 700; letter-spacing: -.2px; }
|
|
19
|
+
select, button, textarea { font: inherit; color: inherit; }
|
|
20
|
+
select { border: 1px solid var(--line); border-radius: 12px; padding: 6px 10px; background: var(--paper); max-width: 320px; }
|
|
21
|
+
.chip { font-size: 12px; color: var(--muted); background: var(--paper);
|
|
22
|
+
border: 1px solid var(--line); border-radius: 999px; padding: 4px 10px; white-space: nowrap; }
|
|
23
|
+
.chip.on { color: var(--leaf); border-color: var(--leaf); background: var(--leaf-soft); }
|
|
24
|
+
#chat { flex: 1; overflow-y: auto; padding: 22px 0; }
|
|
25
|
+
.row { max-width: 780px; margin: 0 auto 14px; padding: 0 18px; display: flex; }
|
|
26
|
+
.bubble { padding: 10px 14px; border-radius: 16px; width: fit-content;
|
|
27
|
+
max-width: 100%; white-space: pre-wrap; overflow-wrap: break-word; }
|
|
28
|
+
[data-role="bubble-wrap"] { min-width: 0; max-width: min(85%, 640px);
|
|
29
|
+
display: flex; flex-direction: column; }
|
|
30
|
+
.row[data-row="user"] [data-role="bubble-wrap"] { margin-left: auto; align-items: flex-end; }
|
|
31
|
+
.me { background: var(--ink); color: var(--paper); border-bottom-right-radius: 4px; }
|
|
32
|
+
.zoo { background: var(--card); border: 1px solid var(--line); border-bottom-left-radius: 4px; }
|
|
33
|
+
.meta { font-size: 11px; color: var(--muted); margin-top: 4px; }
|
|
34
|
+
footer { border-top: 1px solid var(--line); background: var(--card); padding: 12px 18px; }
|
|
35
|
+
.composer { max-width: 780px; margin: 0 auto; display: flex; gap: 10px; align-items: flex-end; }
|
|
36
|
+
textarea { flex: 1; resize: none; border: 1px solid var(--line); border-radius: 14px;
|
|
37
|
+
padding: 10px 14px; background: var(--paper); min-height: 44px; max-height: 160px; outline: none; }
|
|
38
|
+
textarea:focus { border-color: var(--leaf); }
|
|
39
|
+
.send { background: var(--leaf); color: #fff; border: 0; border-radius: 14px;
|
|
40
|
+
padding: 10px 18px; font-weight: 700; cursor: pointer; }
|
|
41
|
+
.send:active { transform: scale(.97); }
|
|
42
|
+
.ticker { max-width: 780px; margin: 8px auto 0; font-size: 12px; color: var(--muted);
|
|
43
|
+
display: flex; gap: 14px; justify-content: center; }
|
|
44
|
+
#drawer { display: none; border-bottom: 1px solid var(--line); background: var(--accent-warm); padding: 14px 18px; }
|
|
45
|
+
#drawer.open { display: block; }
|
|
46
|
+
.drawer-inner { max-width: 780px; margin: 0 auto; }
|
|
47
|
+
.drawer-inner textarea { width: 100%; background: #fffdf7; min-height: 90px; }
|
|
48
|
+
.tiny { font-size: 12px; color: var(--muted); }
|
|
49
|
+
.ghost { background: none; border: 1px solid var(--line); border-radius: 12px; padding: 6px 12px; cursor: pointer; }
|
|
50
|
+
.ghost:hover { border-color: var(--leaf); color: var(--leaf); }
|
|
51
|
+
</style>
|
|
52
|
+
</head>
|
|
53
|
+
<body data-component="app">
|
|
54
|
+
<header data-component="topbar">
|
|
55
|
+
<span data-role="logo" style="font-size:22px">🦓</span>
|
|
56
|
+
<h1 data-role="title">openzoo</h1>
|
|
57
|
+
<input id="model" list="modelList" data-component="model-picker" placeholder="🔎 pick an animal…"
|
|
58
|
+
style="border:1px solid var(--line);border-radius:12px;padding:6px 10px;background:var(--paper);width:280px">
|
|
59
|
+
<datalist id="modelList" data-component="model-datalist"></datalist>
|
|
60
|
+
<button class="ghost" id="brainBtn" data-component="brain-toggle">🧠 teach the zoo</button>
|
|
61
|
+
<button class="ghost" id="newBtn" data-component="new-chat" title="fresh session">✨ new</button>
|
|
62
|
+
<span class="chip" id="ctxChip" data-component="ctx-chip" style="display:none"></span>
|
|
63
|
+
<span style="flex:1" data-role="spacer"></span>
|
|
64
|
+
<span class="chip" id="netChip" data-component="net-chip">connecting…</span>
|
|
65
|
+
</header>
|
|
66
|
+
|
|
67
|
+
<div id="drawer" data-component="brain-drawer">
|
|
68
|
+
<div class="drawer-inner" data-role="drawer-inner">
|
|
69
|
+
<p class="tiny" data-role="drawer-hint" style="margin:0 0 8px">Paste anything big — docs, a codebase, a book. The zoo binds it once
|
|
70
|
+
(free) and every later question retrieves just the relevant slices. That's the whole trick.</p>
|
|
71
|
+
<textarea id="corpus" data-component="corpus-input" placeholder="paste your corpus here… (or drop a .txt/.md file on this box)"></textarea>
|
|
72
|
+
<div style="margin-top:8px; display:flex; gap:10px; align-items:center" data-role="drawer-actions">
|
|
73
|
+
<button class="send" id="bindBtn" data-component="bind-button">bind it</button>
|
|
74
|
+
<span class="tiny" id="bindStatus" data-role="bind-status"></span>
|
|
75
|
+
</div>
|
|
76
|
+
</div>
|
|
77
|
+
</div>
|
|
78
|
+
|
|
79
|
+
<div id="chat" data-component="chat-scroll">
|
|
80
|
+
<div class="row" data-row="welcome"><div class="bubble zoo" data-role="welcome-bubble">welcome to the zoo 🦁 — pick a model up top, say anything.
|
|
81
|
+
paste a huge corpus with 🧠 and ask questions against it for pennies: it never gets re-sent, only retrieved.</div></div>
|
|
82
|
+
</div>
|
|
83
|
+
|
|
84
|
+
<footer data-component="composer-bar">
|
|
85
|
+
<div class="composer" data-role="composer">
|
|
86
|
+
<textarea id="box" data-component="message-input" placeholder="say something to the animals…" rows="1"></textarea>
|
|
87
|
+
<button class="send" id="sendBtn" data-component="send-button">send</button>
|
|
88
|
+
</div>
|
|
89
|
+
<div class="ticker" data-component="ticker">
|
|
90
|
+
<span id="spent" data-role="spent-ticker">this session: $0.0000</span>
|
|
91
|
+
<span id="saved" data-role="saved-ticker"></span>
|
|
92
|
+
<span id="calls" data-role="calls-ticker"></span>
|
|
93
|
+
</div>
|
|
94
|
+
</footer>
|
|
95
|
+
|
|
96
|
+
<script data-component="app-script">
|
|
97
|
+
const $ = (id) => document.getElementById(id);
|
|
98
|
+
const SKEY = "openzoo.session.v1";
|
|
99
|
+
const state = Object.assign(
|
|
100
|
+
{ messages: [], ctx: null, spent: 0, saved: 0, calls: 0, model: "" },
|
|
101
|
+
JSON.parse(localStorage.getItem(SKEY) || "{}"),
|
|
102
|
+
{ busy: false },
|
|
103
|
+
);
|
|
104
|
+
function persist() {
|
|
105
|
+
const { messages, ctx, spent, saved, calls } = state;
|
|
106
|
+
localStorage.setItem(SKEY, JSON.stringify({ messages, ctx, spent, saved, calls, model: $("model")?.value || "" }));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const ANIMAL = (id) => id.startsWith("anthropic") ? "🦒" : id.startsWith("openai") ? "🦉"
|
|
110
|
+
: id.startsWith("deepseek") ? "🐋" : id.startsWith("x-ai") ? "🦊" : id.startsWith("google") ? "🐦"
|
|
111
|
+
: id.startsWith("meta") ? "🦙" : id.startsWith("mistral") ? "🐈" : id.startsWith("qwen") ? "🐼" : "🐾";
|
|
112
|
+
|
|
113
|
+
async function loadModels() {
|
|
114
|
+
try {
|
|
115
|
+
const r = await fetch("/v1/models");
|
|
116
|
+
const d = await r.json();
|
|
117
|
+
const models = (d.data || []).filter((m) => !m.id.startsWith("~") && !m.id.includes(":batch"));
|
|
118
|
+
models.sort((a, b) => a.id.localeCompare(b.id));
|
|
119
|
+
const dl = $("modelList");
|
|
120
|
+
dl.innerHTML = "";
|
|
121
|
+
for (const m of models) {
|
|
122
|
+
const o = document.createElement("option");
|
|
123
|
+
o.value = m.id;
|
|
124
|
+
o.setAttribute("data-option", m.id);
|
|
125
|
+
const inM = m.pricing?.prompt ? (1e6 * m.pricing.prompt).toFixed(2) : "?";
|
|
126
|
+
o.label = `${ANIMAL(m.id)} $${inM}/M`;
|
|
127
|
+
dl.appendChild(o);
|
|
128
|
+
}
|
|
129
|
+
if (!$("model").value) {
|
|
130
|
+
const pick = models.find((m) => m.id.includes("deepseek-v4-pro")) || models[0];
|
|
131
|
+
if (pick) $("model").value = pick.id;
|
|
132
|
+
}
|
|
133
|
+
$("netChip").textContent = `${models.length} animals`;
|
|
134
|
+
$("netChip").classList.add("on");
|
|
135
|
+
} catch { $("netChip").textContent = "proxy unreachable"; }
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function bubble(text, mine, meta) {
|
|
139
|
+
const row = document.createElement("div");
|
|
140
|
+
row.className = "row";
|
|
141
|
+
row.setAttribute("data-row", mine ? "user" : "assistant");
|
|
142
|
+
const wrap = document.createElement("div");
|
|
143
|
+
wrap.setAttribute("data-role", "bubble-wrap");
|
|
144
|
+
const b = document.createElement("div");
|
|
145
|
+
b.className = `bubble ${mine ? "me" : "zoo"}`;
|
|
146
|
+
b.setAttribute("data-role", "bubble");
|
|
147
|
+
b.textContent = text;
|
|
148
|
+
wrap.appendChild(b);
|
|
149
|
+
if (meta) {
|
|
150
|
+
const m = document.createElement("div");
|
|
151
|
+
m.className = "meta";
|
|
152
|
+
m.setAttribute("data-role", "bubble-meta");
|
|
153
|
+
m.textContent = meta;
|
|
154
|
+
wrap.appendChild(m);
|
|
155
|
+
}
|
|
156
|
+
row.appendChild(wrap);
|
|
157
|
+
$("chat").appendChild(row);
|
|
158
|
+
$("chat").scrollTop = $("chat").scrollHeight;
|
|
159
|
+
return b;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async function send() {
|
|
163
|
+
const text = $("box").value.trim();
|
|
164
|
+
if (!text || state.busy) return;
|
|
165
|
+
state.busy = true;
|
|
166
|
+
$("box").value = "";
|
|
167
|
+
bubble(text, true);
|
|
168
|
+
state.messages.push({ role: "user", content: text });
|
|
169
|
+
const thinking = bubble("…", false);
|
|
170
|
+
try {
|
|
171
|
+
const headers = { "content-type": "application/json" };
|
|
172
|
+
if (state.ctx) headers["x-hrr-context"] = state.ctx;
|
|
173
|
+
const r = await fetch("/v1/chat/completions", {
|
|
174
|
+
method: "POST", headers,
|
|
175
|
+
body: JSON.stringify({ model: $("model").value, messages: state.messages, max_tokens: 2048 }),
|
|
176
|
+
});
|
|
177
|
+
const d = await r.json();
|
|
178
|
+
const content = d.choices?.[0]?.message?.content || d.error?.message || JSON.stringify(d).slice(0, 300);
|
|
179
|
+
thinking.textContent = content;
|
|
180
|
+
state.messages.push({ role: "assistant", content });
|
|
181
|
+
const x = d.x402 || {};
|
|
182
|
+
state.calls += 1;
|
|
183
|
+
if (typeof x.billedUsd === "number") state.spent += x.billedUsd;
|
|
184
|
+
if (typeof x.savesVsDirect === "number" && x.savesVsDirect > 0) state.saved += x.savesVsDirect;
|
|
185
|
+
const bits = [];
|
|
186
|
+
if (typeof x.billedUsd === "number") bits.push(`$${x.billedUsd.toFixed(4)}`);
|
|
187
|
+
if (x.lecore?.engaged) bits.push(`🧠 ${x.lecore.recalled ?? "?"} slices`);
|
|
188
|
+
if (bits.length) thinking.parentElement.querySelector("[data-role=bubble-meta]")?.remove(),
|
|
189
|
+
thinking.insertAdjacentHTML("afterend", `<div class="meta" data-role="bubble-meta">${bits.join(" · ")}</div>`);
|
|
190
|
+
$("spent").textContent = `this session: $${state.spent.toFixed(4)}`;
|
|
191
|
+
if (state.saved > 0) $("saved").textContent = `saved ~$${state.saved.toFixed(4)} vs direct 🎉`;
|
|
192
|
+
$("calls").textContent = `${state.calls} call${state.calls === 1 ? "" : "s"}`;
|
|
193
|
+
persist();
|
|
194
|
+
} catch (e) {
|
|
195
|
+
thinking.textContent = `the zoo hiccuped: ${e.message}`;
|
|
196
|
+
}
|
|
197
|
+
state.busy = false;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
async function bind() {
|
|
201
|
+
const corpus = $("corpus").value;
|
|
202
|
+
if (!corpus.trim()) return;
|
|
203
|
+
$("bindStatus").textContent = "binding…";
|
|
204
|
+
try {
|
|
205
|
+
const r = await fetch("/v1/hrr/bind", {
|
|
206
|
+
method: "POST", headers: { "content-type": "application/json" },
|
|
207
|
+
body: JSON.stringify({ corpus }),
|
|
208
|
+
});
|
|
209
|
+
const d = await r.json();
|
|
210
|
+
if (d.context_id) {
|
|
211
|
+
state.ctx = d.context_id;
|
|
212
|
+
$("bindStatus").textContent = `bound ${(corpus.length / 1000).toFixed(0)}k chars ✓`;
|
|
213
|
+
const chip = $("ctxChip");
|
|
214
|
+
chip.style.display = "";
|
|
215
|
+
chip.classList.add("on");
|
|
216
|
+
chip.textContent = `🧠 ${d.context_id.slice(0, 14)}… attached`;
|
|
217
|
+
$("drawer").classList.remove("open");
|
|
218
|
+
persist();
|
|
219
|
+
} else {
|
|
220
|
+
$("bindStatus").textContent = d.error?.message || d.error || "bind failed";
|
|
221
|
+
}
|
|
222
|
+
} catch (e) { $("bindStatus").textContent = e.message; }
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function restore() {
|
|
226
|
+
for (const m of state.messages) bubble(m.content, m.role === "user");
|
|
227
|
+
$("spent").textContent = `this session: $${state.spent.toFixed(4)}`;
|
|
228
|
+
if (state.saved > 0) $("saved").textContent = `saved ~$${state.saved.toFixed(4)} vs direct 🎉`;
|
|
229
|
+
if (state.calls) $("calls").textContent = `${state.calls} call${state.calls === 1 ? "" : "s"}`;
|
|
230
|
+
if (state.model) $("model").value = state.model;
|
|
231
|
+
if (state.ctx) {
|
|
232
|
+
const chip = $("ctxChip");
|
|
233
|
+
chip.style.display = "";
|
|
234
|
+
chip.classList.add("on");
|
|
235
|
+
chip.textContent = `🧠 ${state.ctx.slice(0, 14)}… attached`;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
restore();
|
|
239
|
+
$("newBtn").onclick = () => {
|
|
240
|
+
localStorage.removeItem(SKEY);
|
|
241
|
+
location.reload();
|
|
242
|
+
};
|
|
243
|
+
$("sendBtn").onclick = send;
|
|
244
|
+
$("box").addEventListener("keydown", (e) => {
|
|
245
|
+
if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); send(); }
|
|
246
|
+
});
|
|
247
|
+
$("brainBtn").onclick = () => $("drawer").classList.toggle("open");
|
|
248
|
+
$("bindBtn").onclick = bind;
|
|
249
|
+
$("corpus").addEventListener("drop", async (e) => {
|
|
250
|
+
e.preventDefault();
|
|
251
|
+
const f = e.dataTransfer?.files?.[0];
|
|
252
|
+
if (f) $("corpus").value = await f.text();
|
|
253
|
+
});
|
|
254
|
+
$("corpus").addEventListener("dragover", (e) => e.preventDefault());
|
|
255
|
+
loadModels();
|
|
256
|
+
</script>
|
|
257
|
+
</body>
|
|
258
|
+
</html>
|
package/lib/proxy.js
CHANGED
|
@@ -194,8 +194,29 @@ function serveAsSse(res, data, upstream) {
|
|
|
194
194
|
*/
|
|
195
195
|
const REPLAY_TTL_MS = 30_000;
|
|
196
196
|
const replayCache = new Map(); // sha256(body) -> { at, data, settle }
|
|
197
|
-
|
|
198
|
-
|
|
197
|
+
/**
|
|
198
|
+
* The key MUST include the routing headers, not just the body.
|
|
199
|
+
*
|
|
200
|
+
* Keying on the body alone was a correctness bug, not merely a caching one:
|
|
201
|
+
* N shards asking the SAME question of N DIFFERENT bound corpora send
|
|
202
|
+
* byte-identical bodies and differ only in X-HRR-Context. They collided on one
|
|
203
|
+
* key, so shards 2..N were served shard 1's answer — REPRODUCED in the field:
|
|
204
|
+
* 10 shards, 7 byte-identical replies across corpora known to differ, and the
|
|
205
|
+
* batch finished in 12s where a single uncached call took ~7s.
|
|
206
|
+
*
|
|
207
|
+
* Wrong answers attributed to the wrong corpus is a far worse failure than the
|
|
208
|
+
* double-billing this cache exists to prevent, so every header that can change
|
|
209
|
+
* the ANSWER joins the key.
|
|
210
|
+
*/
|
|
211
|
+
const REPLAY_KEY_HEADERS = ['x-hrr-context', 'x-hrr-top-k', 'x-hrr-gate', 'x-openzoo-namespace'];
|
|
212
|
+
|
|
213
|
+
function replayKey(bodyBuf, headers = {}) {
|
|
214
|
+
const h = crypto.createHash('sha256').update(bodyBuf);
|
|
215
|
+
for (const name of REPLAY_KEY_HEADERS) {
|
|
216
|
+
const v = headers[name];
|
|
217
|
+
if (v) h.update(`\n${name}:${v}`);
|
|
218
|
+
}
|
|
219
|
+
return h.digest('hex');
|
|
199
220
|
}
|
|
200
221
|
function replayGet(key) {
|
|
201
222
|
const hit = replayCache.get(key);
|
|
@@ -331,6 +352,23 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
|
|
|
331
352
|
return;
|
|
332
353
|
}
|
|
333
354
|
|
|
355
|
+
// THE CUTESY GUI. Local browsers hitting GET / get a little chat app —
|
|
356
|
+
// model zoo, bind-a-corpus drawer, live spent/saved ticker off the x402
|
|
357
|
+
// receipts. Tunnel traffic (cf headers) keeps the JSON discovery below:
|
|
358
|
+
// the GUI is the operator's, not the public's.
|
|
359
|
+
{
|
|
360
|
+
const p0 = (req.url || '').split('?')[0];
|
|
361
|
+
const local = !(req.headers['cf-connecting-ip'] || req.headers['cf-ray']);
|
|
362
|
+
if (local && req.method === 'GET' && (p0 === '/' || p0 === '/gui')) {
|
|
363
|
+
try {
|
|
364
|
+
const html = readFileSync(new URL('./gui.html', import.meta.url), 'utf8');
|
|
365
|
+
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
|
|
366
|
+
res.end(html);
|
|
367
|
+
return;
|
|
368
|
+
} catch { /* fall through to proxy behavior if the file is missing */ }
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
334
372
|
const normalized = normalizePath(req.url);
|
|
335
373
|
if (normalized !== req.url) {
|
|
336
374
|
log(`path ${req.url} -> ${normalized} (base_url already ends in /v1)`);
|
|
@@ -531,7 +569,7 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
|
|
|
531
569
|
// Retry of a body we answered seconds ago? Serve the cached completion —
|
|
532
570
|
// never pay twice for a harness's reconnect loop.
|
|
533
571
|
const isChat = req.method === 'POST' && (req.url || '').includes('/chat/completions');
|
|
534
|
-
const rKey = isChat ? replayKey(bodyBuf) : null;
|
|
572
|
+
const rKey = isChat ? replayKey(bodyBuf, req.headers) : null;
|
|
535
573
|
if (rKey) {
|
|
536
574
|
const hit = replayGet(rKey);
|
|
537
575
|
if (hit) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "openzoo",
|
|
3
|
-
"version": "0.35.
|
|
3
|
+
"version": "0.35.3",
|
|
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",
|