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 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
- function replayKey(bodyBuf) {
198
- return crypto.createHash('sha256').update(bodyBuf).digest('hex');
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.1",
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",