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 +4 -4
- package/lib/anthropic.js +106 -0
- package/lib/proxy.js +70 -4
- package/package.json +1 -1
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 (
|
|
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** (
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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",
|