openzoo 0.48.46 → 0.48.48

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.
Files changed (2) hide show
  1. package/lib/proxy.js +68 -6
  2. package/package.json +1 -1
package/lib/proxy.js CHANGED
@@ -66,20 +66,43 @@ async function readBody(req) {
66
66
  return Buffer.concat(chunks);
67
67
  }
68
68
 
69
- /** Pipe an upstream fetch Response to the client, unbuffered (SSE-safe). */
70
- function relay(res, upstream) {
69
+ /** Pipe an upstream fetch Response to the client, unbuffered (SSE-safe).
70
+ *
71
+ * `onReceipt` is called with the gateway's x402 block when it arrives. On a
72
+ * STREAMED call there is no JSON body to carry that block, so the gateway
73
+ * emits it as an SSE COMMENT (`: x402 {...}`) after the last frame — comments
74
+ * are discarded by every compliant client, so nothing downstream sees it, but
75
+ * without reading it here every spend and savings figure on the status line
76
+ * would silently read zero the moment real streaming was switched on.
77
+ *
78
+ * Sniffing NEVER delays a byte: each chunk is written to the client first and
79
+ * only then scanned. */
80
+ function relay(res, upstream, onReceipt) {
71
81
  const headers = {};
72
82
  upstream.headers.forEach((v, k) => {
73
83
  if (!['transfer-encoding', 'connection', 'content-encoding', 'content-length'].includes(k)) headers[k] = v;
74
84
  });
75
85
  res.writeHead(upstream.status, headers);
76
86
  if (!upstream.body) { res.end(); return Promise.resolve(); }
87
+ const sse = (upstream.headers.get('content-type') || '').includes('text/event-stream');
77
88
  return new Promise((resolve) => {
78
89
  const body = Readable.fromWeb(upstream.body);
79
90
  body.on('error', () => res.destroy());
80
91
  res.on('close', () => body.destroy());
81
92
  body.on('end', resolve);
82
- body.pipe(res);
93
+ if (!sse || typeof onReceipt !== 'function') { body.pipe(res); return; }
94
+ let pending = '';
95
+ body.on('data', (c) => {
96
+ res.write(c);
97
+ pending += c.toString('utf8');
98
+ const lines = pending.split('\n');
99
+ pending = lines.pop() ?? ''; // a comment can straddle two chunks
100
+ for (const line of lines) {
101
+ if (!line.startsWith(': x402 ')) continue;
102
+ try { onReceipt(JSON.parse(line.slice(7))); } catch { /* not our frame */ }
103
+ }
104
+ });
105
+ body.on('end', () => res.end());
83
106
  });
84
107
  }
85
108
 
@@ -840,6 +863,10 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
840
863
  // answer back on the way out. See lib/anthropic.js.
841
864
  let anthropicMode = false;
842
865
  let anthropicModel = null;
866
+ // The CLIENT's streaming intent, kept separate from the body we send
867
+ // upstream. The Anthropic lane asks the gateway for a complete message (we
868
+ // can only translate a finished one) while still owing the caller SSE.
869
+ let clientWantsStream = false;
843
870
  let responsesMode = false;
844
871
  let responsesModel = null;
845
872
  let responsesCustom = null; // names of freeform tools needing custom_tool_call on the way back
@@ -889,7 +916,24 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
889
916
  try {
890
917
  const inbound = JSON.parse(bodyBuf.toString('utf8'));
891
918
  anthropicModel = inbound.model;
892
- bodyBuf = Buffer.from(JSON.stringify(anthropicToOpenAI(inbound)));
919
+ // ANTHROPIC CLIENTS CANNOT READ AN OPENAI STREAM.
920
+ //
921
+ // The gateway now streams for real, and `relay()` pipes those frames
922
+ // through untouched — which is correct for an OpenAI client and
923
+ // unreadable to Claude Code, which speaks the Anthropic SSE grammar
924
+ // (message_start / content_block_delta / message_stop). It surfaced as
925
+ // "API returned an empty or malformed response (HTTP 200)": a 200 whose
926
+ // body the client cannot parse.
927
+ //
928
+ // The translation we have (openAIToAnthropic + writeAnthropicSse) works
929
+ // on a COMPLETE message, so this lane asks the gateway not to stream and
930
+ // keeps the buffered translation. That costs Claude Code the
931
+ // time-to-first-byte win until an incremental OpenAI->Anthropic frame
932
+ // translator exists; a readable answer late beats an unreadable one now.
933
+ const converted = anthropicToOpenAI(inbound);
934
+ clientWantsStream = converted.stream === true || inbound.stream === true;
935
+ converted.stream = false;
936
+ bodyBuf = Buffer.from(JSON.stringify(converted));
893
937
  anthropicMode = true;
894
938
  req.url = '/v1/chat/completions';
895
939
  url = `${config.apiBase}${req.url}`;
@@ -916,7 +960,7 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
916
960
  }
917
961
  try {
918
962
  const parsed = JSON.parse(bodyBuf.toString('utf8'));
919
- wantsStream = parsed?.stream === true;
963
+ wantsStream = parsed?.stream === true || clientWantsStream;
920
964
  // REASONING MODELS SPEND max_tokens ON THINKING FIRST.
921
965
  //
922
966
  // The budget covers hidden reasoning AND the visible answer, so a
@@ -1197,7 +1241,25 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
1197
1241
  return;
1198
1242
  }
1199
1243
  }
1200
- await relay(res, response);
1244
+ // A STREAMED call is metered from the gateway's trailing SSE comment —
1245
+ // same figures the JSON path reads out of `data.x402`, same counters, so
1246
+ // the status line does not care which transport served the answer.
1247
+ await relay(res, response, (x) => {
1248
+ if (paid || typeof x?.billedUsd !== 'number') return;
1249
+ sessionSpent += x.billedUsd;
1250
+ sessionCogs += typeof x.cogsUsd === 'number' ? x.cogsUsd : x.billedUsd / MARKUP;
1251
+ sessionDirect += typeof x.directUsd === 'number' ? x.directUsd : x.billedUsd;
1252
+ if (typeof x.actualUsd === 'number' && x.actualUsd >= 0) { sessionActual += x.actualUsd; actualCalls += 1; }
1253
+ if (didSpill) {
1254
+ const lc = x.lecore || {};
1255
+ log(`spill priced (streamed): ${x.pricing} · basis ${x.counterfactualTokensUsed ?? '?'} tok vs sent ${lc.tokensBefore ?? '?'} -> ${lc.tokensAfter ?? '?'} · billed ${(x.billedUsd ?? 0).toFixed(5)} direct ${(x.directUsd ?? 0).toFixed(5)}`);
1256
+ spillSpend += x.billedUsd;
1257
+ spillDirect += typeof x.directUsd === 'number' ? x.directUsd : x.billedUsd;
1258
+ }
1259
+ paidCalls += 1;
1260
+ if (viaTunnel) tunnelSpent += x.billedUsd;
1261
+ say(`credit -> $${x.billedUsd.toFixed(6)} · session $${sessionSpent.toFixed(6)}`);
1262
+ });
1201
1263
  } catch (err) {
1202
1264
  if (err instanceof QuoteTooHighError) {
1203
1265
  log(err.message);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openzoo",
3
- "version": "0.48.46",
3
+ "version": "0.48.48",
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",