openzoo 0.48.50 → 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/lib/anthropic.js +106 -0
- package/lib/proxy.js +47 -4
- 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([
|
|
@@ -955,7 +955,15 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
|
|
|
955
955
|
// translator exists; a readable answer late beats an unreadable one now.
|
|
956
956
|
const converted = anthropicToOpenAI(inbound);
|
|
957
957
|
clientWantsStream = converted.stream === true || inbound.stream === true;
|
|
958
|
-
|
|
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;
|
|
959
967
|
bodyBuf = Buffer.from(JSON.stringify(converted));
|
|
960
968
|
anthropicMode = true;
|
|
961
969
|
req.url = '/v1/chat/completions';
|
|
@@ -1282,7 +1290,7 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
|
|
|
1282
1290
|
// A STREAMED call is metered from the gateway's trailing SSE comment —
|
|
1283
1291
|
// same figures the JSON path reads out of `data.x402`, same counters, so
|
|
1284
1292
|
// the status line does not care which transport served the answer.
|
|
1285
|
-
|
|
1293
|
+
const meterStreamed = (x) => {
|
|
1286
1294
|
if (paid || typeof x?.billedUsd !== 'number') return;
|
|
1287
1295
|
sessionSpent += x.billedUsd;
|
|
1288
1296
|
sessionCogs += typeof x.cogsUsd === 'number' ? x.cogsUsd : x.billedUsd / MARKUP;
|
|
@@ -1297,7 +1305,42 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
|
|
|
1297
1305
|
paidCalls += 1;
|
|
1298
1306
|
if (viaTunnel) tunnelSpent += x.billedUsd;
|
|
1299
1307
|
say(`credit -> $${x.billedUsd.toFixed(6)} · session $${sessionSpent.toFixed(6)}`);
|
|
1300
|
-
}
|
|
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);
|
|
1301
1344
|
} catch (err) {
|
|
1302
1345
|
if (err instanceof QuoteTooHighError) {
|
|
1303
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",
|