openzoo 0.48.49 → 0.48.51

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 CHANGED
@@ -93,7 +93,7 @@ Builds a ~965k-token document with one planted fact, shows that buying direct re
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 (a tiny body — the 3.7MB corpus is not re-priced)
96
+ quote for the ask: $0.000480 · pricing=markup (a tiny body — the 3.7MB corpus is not re-priced)
97
97
  ```
98
98
 
99
99
  If the wallet is funded with USDC or TOKEN 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.
@@ -111,7 +111,7 @@ If the wallet is funded with USDC or TOKEN it pays (capped at `OPENZOO_DEMO_MAX_
111
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
112
 
113
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,
114
+ - **the 402 prices the tiny ask** (honestly labeled `pricing=markup`), typically a few hundredths of a cent instead of re-pricing megabytes,
115
115
  - **the answer still comes from your corpus** — the zoo recalls the relevant slices server-side.
116
116
 
117
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.
@@ -175,9 +175,9 @@ Pin the key with `OPENZOO_TUNNEL_TOKEN` if your IDE stores it. Keys never leave
175
175
  ## Honest pricing note
176
176
 
177
177
  Two bases, reported per call in the 402 (`extra.pricing`):
178
- - **Short prompts price at a markup** (3× provider cost) there's nothing to spill, you're paying for passthrough.
178
+ - **Short prompts price at cost.** There's nothing to spill, so there's no saving to share — you pay what the call cost us, reconciled against the provider's own metered figure after it completes.
179
179
  - **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).
180
- - **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.
180
+ - **Asks against a bound corpus price on the small forwarded body** (the receipt says `pricing=markup`). That is not a discount trick: a few hundred tokens at cost is far below the counterfactual price of shipping the corpus, which is the whole point of binding once.
181
181
 
182
182
  The receipt names which base you got; `extra.directUsd` / `extra.savesVsDirect` let you check the math.
183
183
 
package/lib/anthropic.js CHANGED
@@ -177,3 +177,109 @@ export function writeAnthropicSse(res, message, upstream) {
177
177
  ev('message_stop', {});
178
178
  res.end();
179
179
  }
180
+
181
+ /**
182
+ * Translate an OpenAI SSE stream into an Anthropic one, INCREMENTALLY.
183
+ *
184
+ * The buffered path above exists because the gateway used to answer with a
185
+ * finished JSON body. It streams for real now, and piping those frames straight
186
+ * through gives an Anthropic client a 200 whose body it cannot parse — observed
187
+ * as "API returned an empty or malformed response (HTTP 200)". The stopgap was
188
+ * to ask the gateway not to stream and keep buffering, which is correct and
189
+ * silent: Claude Code sends max_tokens=32000 against a 600-turn transcript, so a
190
+ * turn is MINUTES of zero bytes and reads as a hang.
191
+ *
192
+ * So: same grammar, emitted as it arrives.
193
+ *
194
+ * BLOCK INDICES ARE NOT THE OPENAI ONES. Anthropic numbers content blocks
195
+ * sequentially across the whole message, while OpenAI numbers tool_calls in
196
+ * their own space starting at 0 — which collides with the text block. Tool
197
+ * indices are therefore remapped on first sight and remembered.
198
+ *
199
+ * `onReceipt` takes the gateway's trailing `: x402 {...}` SSE comment, which is
200
+ * swallowed here rather than forwarded: it is ours, and the Anthropic grammar
201
+ * has no room for it.
202
+ */
203
+ export function streamOpenAIToAnthropic(res, upstream, requestedModel, onReceipt) {
204
+ const headers = {
205
+ 'content-type': 'text/event-stream; charset=utf-8',
206
+ 'cache-control': 'no-cache, no-transform',
207
+ 'x-accel-buffering': 'no',
208
+ connection: 'keep-alive',
209
+ };
210
+ const settle = upstream?.headers?.get?.('x-payment-response');
211
+ if (settle) headers['x-payment-response'] = settle;
212
+ res.writeHead(200, headers);
213
+ const ev = (type, data) => res.write(`event: ${type}\ndata: ${JSON.stringify({ type, ...data })}\n\n`);
214
+
215
+ let started = false;
216
+ let textIndex = -1; // Anthropic index of the text block, -1 until opened
217
+ let nextIndex = 0; // next free Anthropic block index
218
+ const toolIndex = new Map(); // OpenAI tool_calls[].index -> Anthropic index
219
+ const open = new Set();
220
+ let stopReason = 'end_turn';
221
+ let usage = null;
222
+ let msgId = null;
223
+
224
+ const start = () => {
225
+ if (started) return;
226
+ started = true;
227
+ ev('message_start', {
228
+ message: {
229
+ id: msgId ?? `msg_${Date.now()}`, type: 'message', role: 'assistant',
230
+ model: requestedModel, content: [], stop_reason: null, stop_sequence: null,
231
+ usage: { input_tokens: 0, output_tokens: 0 },
232
+ },
233
+ });
234
+ };
235
+
236
+ const onChunk = (d) => {
237
+ if (d.id && !msgId) msgId = d.id;
238
+ if (d.usage) usage = d.usage;
239
+ const ch = (d.choices ?? [])[0];
240
+ if (!ch) return;
241
+ start();
242
+ const delta = ch.delta ?? {};
243
+ if (typeof delta.content === 'string' && delta.content.length) {
244
+ if (textIndex < 0) {
245
+ textIndex = nextIndex++;
246
+ ev('content_block_start', { index: textIndex, content_block: { type: 'text', text: '' } });
247
+ open.add(textIndex);
248
+ }
249
+ ev('content_block_delta', { index: textIndex, delta: { type: 'text_delta', text: delta.content } });
250
+ }
251
+ for (const t of delta.tool_calls ?? []) {
252
+ const k = t.index ?? 0;
253
+ if (!toolIndex.has(k)) {
254
+ const idx = nextIndex++;
255
+ toolIndex.set(k, idx);
256
+ ev('content_block_start', {
257
+ index: idx,
258
+ content_block: { type: 'tool_use', id: t.id ?? `toolu_${idx}`, name: t.function?.name ?? '', input: {} },
259
+ });
260
+ open.add(idx);
261
+ }
262
+ const frag = t.function?.arguments;
263
+ if (typeof frag === 'string' && frag.length) {
264
+ ev('content_block_delta', {
265
+ index: toolIndex.get(k),
266
+ delta: { type: 'input_json_delta', partial_json: frag },
267
+ });
268
+ }
269
+ }
270
+ if (ch.finish_reason) stopReason = STOP_REASON[ch.finish_reason] ?? 'end_turn';
271
+ };
272
+
273
+ const finish = () => {
274
+ start(); // a stream that produced nothing still owes the client a message
275
+ for (const i of open) ev('content_block_stop', { index: i });
276
+ ev('message_delta', {
277
+ delta: { stop_reason: stopReason, stop_sequence: null },
278
+ usage: { output_tokens: usage?.completion_tokens ?? 0 },
279
+ });
280
+ ev('message_stop', {});
281
+ res.end();
282
+ };
283
+
284
+ return { onChunk, finish, receipt: onReceipt, usageOf: () => usage };
285
+ }
package/lib/proxy.js CHANGED
@@ -15,7 +15,7 @@ import { maybeRewriteModel, rewritablePath, augmentModelList, ALIAS_IDS } from '
15
15
  import { forgetContext } from './contexts.js';
16
16
  import { injectBrief } from './brief.js';
17
17
  import { withNamespace } from './namespace.js';
18
- import { anthropicToOpenAI, openAIToAnthropic, writeAnthropicSse } from './anthropic.js';
18
+ import { anthropicToOpenAI, openAIToAnthropic, streamOpenAIToAnthropic, writeAnthropicSse } from './anthropic.js';
19
19
  import { responsesToChat, chatToResponses, writeResponsesSse } from './responses.js';
20
20
 
21
21
  const HOP_BY_HOP = new Set([
@@ -391,6 +391,29 @@ async function spillTranscript(body, log) {
391
391
  }
392
392
  if (tailStart > cut) cut = tailStart;
393
393
 
394
+ // NEVER SPILL THE CURRENT ASK.
395
+ //
396
+ // The tail budget walks BACKWARD accumulating bytes, and in an agent loop the
397
+ // last few messages are tool results — file reads, greps, build output. Those
398
+ // alone blow through 6,000 chars, so the cut lands AFTER the user's actual
399
+ // instruction and the instruction goes into the bound corpus instead of the
400
+ // forwarded window. It then only comes back if top-k recall happens to surface
401
+ // it against its own text, which is exactly the query it is least likely to
402
+ // match.
403
+ //
404
+ // OBSERVED: "sending 2/578 turns", and the model replying "I don't have a
405
+ // specific request to act on — your message came through empty", then
406
+ // re-reading the plan doc to work out where it was, every single turn. A loop
407
+ // that looks like amnesia and is actually us deleting the question.
408
+ //
409
+ // Retrieval is for CONTEXT. The ask itself is never context, and must survive
410
+ // any budget.
411
+ let lastUser = -1;
412
+ for (let i = msgs.length - 1; i > firstSpillable; i--) {
413
+ if (msgs[i].role === 'user' && msgText(msgs[i]).trim()) { lastUser = i; break; }
414
+ }
415
+ if (lastUser > firstSpillable && cut > lastUser) cut = lastUser;
416
+
394
417
  const head = msgs.slice(0, firstSpillable); // system block, always kept
395
418
  const corpus = msgs.slice(firstSpillable, cut).map(msgText).filter(Boolean).join('\n\n');
396
419
  if (corpus.length <= BIND_MIN_CHARS) return null;
@@ -932,7 +955,15 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
932
955
  // translator exists; a readable answer late beats an unreadable one now.
933
956
  const converted = anthropicToOpenAI(inbound);
934
957
  clientWantsStream = converted.stream === true || inbound.stream === true;
935
- converted.stream = false;
958
+ // STREAM AGAIN. This forced `stream:false` for a few hours because
959
+ // relay() piped the gateway's OpenAI frames straight to a client that
960
+ // speaks message_start / content_block_delta, producing a 200 nobody
961
+ // could parse. Buffering fixed the parse and cost the whole point:
962
+ // Claude Code sends max_tokens=32000 against a 600-turn transcript, so
963
+ // every turn became minutes of zero bytes and read as a hang.
964
+ // streamOpenAIToAnthropic() translates the grammar frame by frame, so
965
+ // the lane can be fast AND readable.
966
+ converted.stream = clientWantsStream;
936
967
  bodyBuf = Buffer.from(JSON.stringify(converted));
937
968
  anthropicMode = true;
938
969
  req.url = '/v1/chat/completions';
@@ -1259,7 +1290,7 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
1259
1290
  // A STREAMED call is metered from the gateway's trailing SSE comment —
1260
1291
  // same figures the JSON path reads out of `data.x402`, same counters, so
1261
1292
  // the status line does not care which transport served the answer.
1262
- await relay(res, response, (x) => {
1293
+ const meterStreamed = (x) => {
1263
1294
  if (paid || typeof x?.billedUsd !== 'number') return;
1264
1295
  sessionSpent += x.billedUsd;
1265
1296
  sessionCogs += typeof x.cogsUsd === 'number' ? x.cogsUsd : x.billedUsd / MARKUP;
@@ -1274,7 +1305,42 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
1274
1305
  paidCalls += 1;
1275
1306
  if (viaTunnel) tunnelSpent += x.billedUsd;
1276
1307
  say(`credit -> $${x.billedUsd.toFixed(6)} · session $${sessionSpent.toFixed(6)}`);
1277
- });
1308
+ };
1309
+
1310
+ // ANTHROPIC CLIENTS GET THE SAME STREAM, IN THEIR OWN GRAMMAR.
1311
+ // Translated frame by frame rather than buffered, so Claude Code sees
1312
+ // tokens as they are produced instead of nothing until the turn ends.
1313
+ if (anthropicMode && wantsStream
1314
+ && (response.headers.get('content-type') || '').includes('text/event-stream')
1315
+ && response.ok && response.body) {
1316
+ const tr = streamOpenAIToAnthropic(res, response, anthropicModel, meterStreamed);
1317
+ let pending = '';
1318
+ const body = Readable.fromWeb(response.body);
1319
+ res.on('close', () => body.destroy());
1320
+ try {
1321
+ for await (const c of body) {
1322
+ pending += c.toString('utf8');
1323
+ const lines = pending.split('\n');
1324
+ pending = lines.pop() ?? '';
1325
+ for (const line of lines) {
1326
+ if (line.startsWith(': x402 ')) {
1327
+ try { meterStreamed(JSON.parse(line.slice(7))); } catch { /* not ours */ }
1328
+ continue;
1329
+ }
1330
+ if (!line.startsWith('data:')) continue;
1331
+ const payload = line.slice(5).trim();
1332
+ if (!payload || payload === '[DONE]') continue;
1333
+ try { tr.onChunk(JSON.parse(payload)); } catch { /* frame split across chunks */ }
1334
+ }
1335
+ }
1336
+ } catch (e) {
1337
+ log(`anthropic stream aborted: ${e.message}`);
1338
+ }
1339
+ tr.finish();
1340
+ return;
1341
+ }
1342
+
1343
+ await relay(res, response, meterStreamed);
1278
1344
  } catch (err) {
1279
1345
  if (err instanceof QuoteTooHighError) {
1280
1346
  log(err.message);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openzoo",
3
- "version": "0.48.49",
3
+ "version": "0.48.51",
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",