openzoo 0.48.50 → 0.48.52
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/anthropic.js +106 -0
- package/lib/proxy.js +87 -8
- package/package.json +1 -1
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([
|
|
@@ -311,7 +311,7 @@ function msgText(m) {
|
|
|
311
311
|
* severed at a plain `user` message — everything before one is self-contained.
|
|
312
312
|
* A system message is never spilled: it is the operating contract, not history.
|
|
313
313
|
*/
|
|
314
|
-
async function spillTranscript(body, log) {
|
|
314
|
+
async function spillTranscript(body, log, req) {
|
|
315
315
|
const msgs = Array.isArray(body?.messages) ? body.messages : null;
|
|
316
316
|
if (!msgs || msgs.length < 6) return null;
|
|
317
317
|
|
|
@@ -391,6 +391,28 @@ async function spillTranscript(body, log) {
|
|
|
391
391
|
}
|
|
392
392
|
if (tailStart > cut) cut = tailStart;
|
|
393
393
|
|
|
394
|
+
// COHERENCE IS COUNTED IN TURNS, NOT BYTES.
|
|
395
|
+
//
|
|
396
|
+
// The byte budget alone produced windows of 2, 5 and 34 turns out of ~600 —
|
|
397
|
+
// and one fat tool result is enough to spend the whole 6,000 chars, so a busy
|
|
398
|
+
// agent turn collapses the window to almost nothing. OBSERVED live: an agent
|
|
399
|
+
// that had just run a command reported "I don't have the preceding turns of
|
|
400
|
+
// this conversation in view" and re-derived its own state from files, every
|
|
401
|
+
// turn. Retrieval brings back what is RELEVANT to the ask; it does not
|
|
402
|
+
// reliably bring back "what I just did", because the model does not know to
|
|
403
|
+
// query for it.
|
|
404
|
+
//
|
|
405
|
+
// So floor the window at a number of turns regardless of size. This costs
|
|
406
|
+
// saving — a bigger tail is a bigger `sent` — and that is the correct trade:
|
|
407
|
+
// measured 8.13x on the fleet leaves room to spend some of it on an agent
|
|
408
|
+
// that remembers its own last few moves.
|
|
409
|
+
const minTurns = Number(process.env.OPENZOO_TAIL_MIN_TURNS || 12);
|
|
410
|
+
if (msgs.length - cut < minTurns) {
|
|
411
|
+
for (let i = Math.max(firstSpillable + 1, msgs.length - minTurns); i > firstSpillable; i--) {
|
|
412
|
+
if (severable(i)) { cut = i; break; }
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
394
416
|
// NEVER SPILL THE CURRENT ASK.
|
|
395
417
|
//
|
|
396
418
|
// The tail budget walks BACKWARD accumulating bytes, and in an agent loop the
|
|
@@ -430,7 +452,21 @@ async function spillTranscript(body, log) {
|
|
|
430
452
|
// one. So when it does, send just the tail and keep the same context_id.
|
|
431
453
|
// Anchored on the FIRST 2KB, which is stable for the life of a conversation
|
|
432
454
|
// and distinguishes concurrent ones.
|
|
433
|
-
|
|
455
|
+
// KEY ON THE SESSION, NOT ON THE CONTENT.
|
|
456
|
+
//
|
|
457
|
+
// The anchor was the first 2KB of corpus, which works only because a
|
|
458
|
+
// transcript's opening never changes. It is fragile in exactly the cases that
|
|
459
|
+
// matter: two sessions that open identically (same system block, same first
|
|
460
|
+
// instruction — the norm for an agent) collide onto ONE bound context and
|
|
461
|
+
// interleave their histories, and any edit near the top of a transcript
|
|
462
|
+
// silently orphans the binding and re-uploads the whole thing.
|
|
463
|
+
//
|
|
464
|
+
// Claude Code identifies its session, so use that when it is offered and fall
|
|
465
|
+
// back to the content anchor when it is not. Same memo, better key.
|
|
466
|
+
const sessionId = req?.headers?.['x-session-id']
|
|
467
|
+
|| req?.headers?.['x-claude-session-id']
|
|
468
|
+
|| (typeof body?.metadata?.user_id === 'string' ? body.metadata.user_id : null);
|
|
469
|
+
const anchor = sessionId ? `sid:${sessionId}` : corpus.slice(0, 2048);
|
|
434
470
|
const prior = spillMemo.get(anchor);
|
|
435
471
|
let bind;
|
|
436
472
|
if (prior && corpus.startsWith(prior.corpus) && corpus.length > prior.corpus.length) {
|
|
@@ -515,11 +551,11 @@ async function maybeCacheCorpus(req, bodyBuf, log) {
|
|
|
515
551
|
const oneShot = typeof last?.content === 'string'
|
|
516
552
|
&& last.content.length > BIND_MIN_CHARS
|
|
517
553
|
&& last.content.lastIndexOf('\n\n') >= BIND_MIN_CHARS;
|
|
518
|
-
if (!oneShot) return spillTranscript(body, log);
|
|
554
|
+
if (!oneShot) return spillTranscript(body, log, req);
|
|
519
555
|
const cut = last.content.lastIndexOf('\n\n');
|
|
520
556
|
const corpus = last.content.slice(0, cut);
|
|
521
557
|
const ask = last.content.slice(cut + 2).trim();
|
|
522
|
-
if (!ask || ask.length > 8000) return spillTranscript(body, log);
|
|
558
|
+
if (!ask || ask.length > 8000) return spillTranscript(body, log, req);
|
|
523
559
|
|
|
524
560
|
const bind = await bindCorpus(corpus, {
|
|
525
561
|
onStage: (stage, info) => {
|
|
@@ -955,7 +991,15 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
|
|
|
955
991
|
// translator exists; a readable answer late beats an unreadable one now.
|
|
956
992
|
const converted = anthropicToOpenAI(inbound);
|
|
957
993
|
clientWantsStream = converted.stream === true || inbound.stream === true;
|
|
958
|
-
|
|
994
|
+
// STREAM AGAIN. This forced `stream:false` for a few hours because
|
|
995
|
+
// relay() piped the gateway's OpenAI frames straight to a client that
|
|
996
|
+
// speaks message_start / content_block_delta, producing a 200 nobody
|
|
997
|
+
// could parse. Buffering fixed the parse and cost the whole point:
|
|
998
|
+
// Claude Code sends max_tokens=32000 against a 600-turn transcript, so
|
|
999
|
+
// every turn became minutes of zero bytes and read as a hang.
|
|
1000
|
+
// streamOpenAIToAnthropic() translates the grammar frame by frame, so
|
|
1001
|
+
// the lane can be fast AND readable.
|
|
1002
|
+
converted.stream = clientWantsStream;
|
|
959
1003
|
bodyBuf = Buffer.from(JSON.stringify(converted));
|
|
960
1004
|
anthropicMode = true;
|
|
961
1005
|
req.url = '/v1/chat/completions';
|
|
@@ -1282,7 +1326,7 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
|
|
|
1282
1326
|
// A STREAMED call is metered from the gateway's trailing SSE comment —
|
|
1283
1327
|
// same figures the JSON path reads out of `data.x402`, same counters, so
|
|
1284
1328
|
// the status line does not care which transport served the answer.
|
|
1285
|
-
|
|
1329
|
+
const meterStreamed = (x) => {
|
|
1286
1330
|
if (paid || typeof x?.billedUsd !== 'number') return;
|
|
1287
1331
|
sessionSpent += x.billedUsd;
|
|
1288
1332
|
sessionCogs += typeof x.cogsUsd === 'number' ? x.cogsUsd : x.billedUsd / MARKUP;
|
|
@@ -1297,7 +1341,42 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
|
|
|
1297
1341
|
paidCalls += 1;
|
|
1298
1342
|
if (viaTunnel) tunnelSpent += x.billedUsd;
|
|
1299
1343
|
say(`credit -> $${x.billedUsd.toFixed(6)} · session $${sessionSpent.toFixed(6)}`);
|
|
1300
|
-
}
|
|
1344
|
+
};
|
|
1345
|
+
|
|
1346
|
+
// ANTHROPIC CLIENTS GET THE SAME STREAM, IN THEIR OWN GRAMMAR.
|
|
1347
|
+
// Translated frame by frame rather than buffered, so Claude Code sees
|
|
1348
|
+
// tokens as they are produced instead of nothing until the turn ends.
|
|
1349
|
+
if (anthropicMode && wantsStream
|
|
1350
|
+
&& (response.headers.get('content-type') || '').includes('text/event-stream')
|
|
1351
|
+
&& response.ok && response.body) {
|
|
1352
|
+
const tr = streamOpenAIToAnthropic(res, response, anthropicModel, meterStreamed);
|
|
1353
|
+
let pending = '';
|
|
1354
|
+
const body = Readable.fromWeb(response.body);
|
|
1355
|
+
res.on('close', () => body.destroy());
|
|
1356
|
+
try {
|
|
1357
|
+
for await (const c of body) {
|
|
1358
|
+
pending += c.toString('utf8');
|
|
1359
|
+
const lines = pending.split('\n');
|
|
1360
|
+
pending = lines.pop() ?? '';
|
|
1361
|
+
for (const line of lines) {
|
|
1362
|
+
if (line.startsWith(': x402 ')) {
|
|
1363
|
+
try { meterStreamed(JSON.parse(line.slice(7))); } catch { /* not ours */ }
|
|
1364
|
+
continue;
|
|
1365
|
+
}
|
|
1366
|
+
if (!line.startsWith('data:')) continue;
|
|
1367
|
+
const payload = line.slice(5).trim();
|
|
1368
|
+
if (!payload || payload === '[DONE]') continue;
|
|
1369
|
+
try { tr.onChunk(JSON.parse(payload)); } catch { /* frame split across chunks */ }
|
|
1370
|
+
}
|
|
1371
|
+
}
|
|
1372
|
+
} catch (e) {
|
|
1373
|
+
log(`anthropic stream aborted: ${e.message}`);
|
|
1374
|
+
}
|
|
1375
|
+
tr.finish();
|
|
1376
|
+
return;
|
|
1377
|
+
}
|
|
1378
|
+
|
|
1379
|
+
await relay(res, response, meterStreamed);
|
|
1301
1380
|
} catch (err) {
|
|
1302
1381
|
if (err instanceof QuoteTooHighError) {
|
|
1303
1382
|
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.52",
|
|
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",
|