openzoo 0.34.10 → 0.34.11
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/cursorbackend.js +65 -14
- package/package.json +1 -1
package/lib/cursorbackend.js
CHANGED
|
@@ -27,6 +27,7 @@
|
|
|
27
27
|
* + trailers for grpc-web. Both are handled below.
|
|
28
28
|
*/
|
|
29
29
|
import http2 from 'node:http2';
|
|
30
|
+
import https from 'node:https';
|
|
30
31
|
import fs from 'node:fs';
|
|
31
32
|
import os from 'node:os';
|
|
32
33
|
import path from 'node:path';
|
|
@@ -236,17 +237,36 @@ const HOP_BY_HOP = new Set([
|
|
|
236
237
|
* over UDP (which ignores /etc/hosts entirely), cache it, and dial the address
|
|
237
238
|
* with the Host/SNI still set to the real hostname so TLS and routing succeed.
|
|
238
239
|
*/
|
|
239
|
-
|
|
240
|
+
// KEYED BY HOST. A single shared slot meant whichever of api/api-staging
|
|
241
|
+
// resolved first pinned its address for the other one too.
|
|
242
|
+
const realIpCache = new Map();
|
|
240
243
|
async function resolveRealAnthropic(host) {
|
|
241
|
-
|
|
244
|
+
const hit = realIpCache.get(host);
|
|
245
|
+
if (hit) return hit;
|
|
242
246
|
const { Resolver } = await import('node:dns/promises');
|
|
243
247
|
const r = new Resolver();
|
|
244
248
|
r.setServers(['1.1.1.1', '8.8.8.8']);
|
|
245
249
|
const [ip] = await r.resolve4(host);
|
|
246
|
-
realIpCache
|
|
250
|
+
realIpCache.set(host, ip);
|
|
247
251
|
return ip;
|
|
248
252
|
}
|
|
249
253
|
|
|
254
|
+
/**
|
|
255
|
+
* Headers we must NOT copy back from the upstream response.
|
|
256
|
+
*
|
|
257
|
+
* DELIBERATELY NARROWER THAN HOP_BY_HOP: `content-encoding` is absent here on
|
|
258
|
+
* purpose. Unlike fetch(), node:https hands us the body EXACTLY as it arrived —
|
|
259
|
+
* still gzip/brotli compressed. Stripping the encoding header while forwarding
|
|
260
|
+
* compressed bytes tells the app "this is plain JSON" over binary garbage, and
|
|
261
|
+
* it fails to parse the bootstrap payload. Forward body and label together.
|
|
262
|
+
* `content-length` still goes, because we set it from the buffer we actually
|
|
263
|
+
* have, and the h2 layer rejects the connection-specific ones outright.
|
|
264
|
+
*/
|
|
265
|
+
const RESP_DROP = new Set([
|
|
266
|
+
'connection', 'keep-alive', 'upgrade', 'proxy-connection',
|
|
267
|
+
'transfer-encoding', 'te', 'trailer', 'content-length',
|
|
268
|
+
]);
|
|
269
|
+
|
|
250
270
|
async function passthroughToRealAnthropic(req, res, body, host, full, log) {
|
|
251
271
|
try {
|
|
252
272
|
const ip = await resolveRealAnthropic(host);
|
|
@@ -256,18 +276,42 @@ async function passthroughToRealAnthropic(req, res, body, host, full, log) {
|
|
|
256
276
|
headers[k] = Array.isArray(v) ? v.join(', ') : String(v);
|
|
257
277
|
}
|
|
258
278
|
headers.host = host;
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
279
|
+
|
|
280
|
+
// DIAL THE ADDRESS, BUT SPEAK THE NAME. fetch(`https://${ip}/...`) cannot do
|
|
281
|
+
// this: undici derives SNI from the URL, so the server saw a bare IP, had no
|
|
282
|
+
// certificate to offer for it, and killed the handshake
|
|
283
|
+
// (ERR_SSL_SSL/TLS_ALERT_HANDSHAKE_FAILURE). node:https lets the two be set
|
|
284
|
+
// independently — a custom `lookup` pins the connection to the public-DNS
|
|
285
|
+
// address (bypassing our own /etc/hosts entry, which would otherwise loop
|
|
286
|
+
// this request straight back into this very server), while `servername` and
|
|
287
|
+
// the Host header keep presenting the real hostname so the cert validates.
|
|
288
|
+
const lookup = (h, o, cb) => (o && o.all ? cb(null, [{ address: ip, family: 4 }]) : cb(null, ip, 4));
|
|
289
|
+
const { status, respHeaders, buf } = await new Promise((resolve, reject) => {
|
|
290
|
+
const up = https.request({
|
|
291
|
+
hostname: host, servername: host, port: 443, path: full,
|
|
292
|
+
method: req.method, headers, lookup,
|
|
293
|
+
}, (r) => {
|
|
294
|
+
const chunks = [];
|
|
295
|
+
r.on('data', (d) => chunks.push(d));
|
|
296
|
+
r.on('end', () => resolve({
|
|
297
|
+
status: r.statusCode, respHeaders: r.headers, buf: Buffer.concat(chunks),
|
|
298
|
+
}));
|
|
299
|
+
r.on('error', reject);
|
|
300
|
+
});
|
|
301
|
+
up.on('error', reject);
|
|
302
|
+
up.setTimeout(20000, () => up.destroy(new Error('upstream timeout')));
|
|
303
|
+
if (req.method !== 'GET' && req.method !== 'HEAD' && body && body.length) up.write(body);
|
|
304
|
+
up.end();
|
|
264
305
|
});
|
|
265
|
-
|
|
306
|
+
|
|
266
307
|
const h = {};
|
|
267
|
-
|
|
268
|
-
|
|
308
|
+
for (const [k, v] of Object.entries(respHeaders)) {
|
|
309
|
+
if (RESP_DROP.has(k.toLowerCase())) continue;
|
|
310
|
+
h[k] = v;
|
|
311
|
+
}
|
|
312
|
+
res.writeHead(status, h);
|
|
269
313
|
res.end(buf);
|
|
270
|
-
log(`cursor-backend: ${req.method} ${full} -> REAL anthropic (${
|
|
314
|
+
log(`cursor-backend: ${req.method} ${full} -> REAL anthropic (${status}, ${buf.length}b)`);
|
|
271
315
|
} catch (e) {
|
|
272
316
|
res.writeHead(502, { 'content-type': 'application/json' });
|
|
273
317
|
res.end(JSON.stringify({ type: 'error', error: { type: 'api_error', message: `passthrough failed: ${e.message}` } }));
|
|
@@ -360,8 +404,15 @@ export function startCursorBackend({ port = 8443, models, log = () => {} } = {})
|
|
|
360
404
|
// verbatim to the local paying proxy and relay the response — reusing the
|
|
361
405
|
// whole translate+x402 path. (SNI is checked because the Host header may be
|
|
362
406
|
// absent on h2.)
|
|
363
|
-
|
|
364
|
-
|
|
407
|
+
// STRIP THE PORT BEFORE MATCHING. :authority / Host carry `host:port`
|
|
408
|
+
// whenever the client dials a non-default port, so an anchored
|
|
409
|
+
// /anthropic\.com$/ silently failed to match and the request fell through
|
|
410
|
+
// to the gRPC branch, which answered empty-OK. The desktop app then saw a
|
|
411
|
+
// 200 with no body for its session bootstrap — indistinguishable from the
|
|
412
|
+
// 404 it used to get, and it wedged the same way.
|
|
413
|
+
const rawHost = String(req.headers[':authority'] || req.headers.host || req.socket?.servername || '');
|
|
414
|
+
const host = rawHost.replace(/:\d+$/, '');
|
|
415
|
+
const isAnthropic = /(^|\.)anthropic\.com$/.test(host);
|
|
365
416
|
// INTERCEPT INFERENCE ONLY. Matching the whole HOST sent every request the
|
|
366
417
|
// desktop app makes to the local proxy — including the session bootstrap
|
|
367
418
|
// (/api/organizations, /api/bootstrap, /api/account), which the proxy does
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "openzoo",
|
|
3
|
-
"version": "0.34.
|
|
3
|
+
"version": "0.34.11",
|
|
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",
|