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 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
- 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;
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
- await relay(res, response, (x) => {
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.50",
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",