openzoo 0.34.9 → 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 +132 -8
- 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';
|
|
@@ -210,6 +211,114 @@ async function handleStreamChat(req, res, body, log) {
|
|
|
210
211
|
else { const end = Buffer.from('{}'); const h = Buffer.alloc(5); h.writeUInt8(0x02, 0); h.writeUInt32BE(end.length, 1); res.end(Buffer.concat([h, end])); }
|
|
211
212
|
}
|
|
212
213
|
|
|
214
|
+
/** Headers that are illegal to copy onto an HTTP/2 response (RFC 7540 §8.1.2.2),
|
|
215
|
+
* plus the length/encoding fields that no longer describe our re-buffered body. */
|
|
216
|
+
const HOP_BY_HOP = new Set([
|
|
217
|
+
'connection', 'keep-alive', 'upgrade', 'proxy-connection',
|
|
218
|
+
'transfer-encoding', 'te', 'trailer', 'proxy-authenticate',
|
|
219
|
+
'proxy-authorization', 'content-encoding', 'content-length',
|
|
220
|
+
]);
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* FORWARD A NON-INFERENCE REQUEST TO THE REAL ANTHROPIC API.
|
|
224
|
+
*
|
|
225
|
+
* We hijack api.anthropic.com at the DNS level, which catches far more than
|
|
226
|
+
* inference: the desktop app is OAuth/subscription-bound and boots by calling
|
|
227
|
+
* /api/organizations, /api/bootstrap, /api/account on the SAME host. Those are
|
|
228
|
+
* not ours to answer — the local proxy 404s them, the app cannot establish a
|
|
229
|
+
* session, and it hangs until macOS offers Force Quit.
|
|
230
|
+
*
|
|
231
|
+
* So for anything that is not inference we act as a plain reverse proxy to the
|
|
232
|
+
* genuine API, preserving the client's auth headers verbatim so the app's own
|
|
233
|
+
* OAuth session keeps working untouched.
|
|
234
|
+
*
|
|
235
|
+
* WE CANNOT RESOLVE THE NAME NORMALLY — /etc/hosts points it at us, so a plain
|
|
236
|
+
* fetch would loop straight back into this server. Resolve against public DNS
|
|
237
|
+
* over UDP (which ignores /etc/hosts entirely), cache it, and dial the address
|
|
238
|
+
* with the Host/SNI still set to the real hostname so TLS and routing succeed.
|
|
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();
|
|
243
|
+
async function resolveRealAnthropic(host) {
|
|
244
|
+
const hit = realIpCache.get(host);
|
|
245
|
+
if (hit) return hit;
|
|
246
|
+
const { Resolver } = await import('node:dns/promises');
|
|
247
|
+
const r = new Resolver();
|
|
248
|
+
r.setServers(['1.1.1.1', '8.8.8.8']);
|
|
249
|
+
const [ip] = await r.resolve4(host);
|
|
250
|
+
realIpCache.set(host, ip);
|
|
251
|
+
return ip;
|
|
252
|
+
}
|
|
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
|
+
|
|
270
|
+
async function passthroughToRealAnthropic(req, res, body, host, full, log) {
|
|
271
|
+
try {
|
|
272
|
+
const ip = await resolveRealAnthropic(host);
|
|
273
|
+
const headers = {};
|
|
274
|
+
for (const [k, v] of Object.entries(req.headers)) {
|
|
275
|
+
if (k.startsWith(':') || HOP_BY_HOP.has(k)) continue;
|
|
276
|
+
headers[k] = Array.isArray(v) ? v.join(', ') : String(v);
|
|
277
|
+
}
|
|
278
|
+
headers.host = host;
|
|
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();
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
const h = {};
|
|
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);
|
|
313
|
+
res.end(buf);
|
|
314
|
+
log(`cursor-backend: ${req.method} ${full} -> REAL anthropic (${status}, ${buf.length}b)`);
|
|
315
|
+
} catch (e) {
|
|
316
|
+
res.writeHead(502, { 'content-type': 'application/json' });
|
|
317
|
+
res.end(JSON.stringify({ type: 'error', error: { type: 'api_error', message: `passthrough failed: ${e.message}` } }));
|
|
318
|
+
log(`cursor-backend: passthrough ${full} FAILED: ${e.message}`);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
213
322
|
/**
|
|
214
323
|
* Answer one Connect/gRPC-web call. `method` is the trailing method name,
|
|
215
324
|
* `models` the catalog to publish. Non-catalog methods get an empty-OK body.
|
|
@@ -295,8 +404,28 @@ export function startCursorBackend({ port = 8443, models, log = () => {} } = {})
|
|
|
295
404
|
// verbatim to the local paying proxy and relay the response — reusing the
|
|
296
405
|
// whole translate+x402 path. (SNI is checked because the Host header may be
|
|
297
406
|
// absent on h2.)
|
|
298
|
-
|
|
299
|
-
|
|
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);
|
|
416
|
+
// INTERCEPT INFERENCE ONLY. Matching the whole HOST sent every request the
|
|
417
|
+
// desktop app makes to the local proxy — including the session bootstrap
|
|
418
|
+
// (/api/organizations, /api/bootstrap, /api/account), which the proxy does
|
|
419
|
+
// not implement and answered 404. The app is OAuth/subscription-bound, so
|
|
420
|
+
// a 404 on bootstrap means it can never establish a session: it wedges and
|
|
421
|
+
// macOS offers Force Quit. Only /v1/messages (and /v1/complete) are ours;
|
|
422
|
+
// everything else on this host MUST reach the real Anthropic API.
|
|
423
|
+
const isInference = /\/v1\/(messages|complete)\b/.test(full);
|
|
424
|
+
if (isAnthropic && !isInference) {
|
|
425
|
+
await passthroughToRealAnthropic(req, res, body, host, full, log);
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
428
|
+
if (isAnthropic || isInference) {
|
|
300
429
|
try {
|
|
301
430
|
const up = await fetch(`http://127.0.0.1:8402${full}`, {
|
|
302
431
|
method: req.method,
|
|
@@ -313,12 +442,7 @@ export function startCursorBackend({ port = 8443, models, log = () => {} } = {})
|
|
|
313
442
|
// desktop request killed the whole backend process (uncaught, since
|
|
314
443
|
// this is inside the async handler), and every later attempt got
|
|
315
444
|
// ECONNRESET with nothing listening. Content-length goes too: the
|
|
316
|
-
// body was re-buffered, so let Node recompute it.
|
|
317
|
-
const HOP_BY_HOP = new Set([
|
|
318
|
-
'connection', 'keep-alive', 'upgrade', 'proxy-connection',
|
|
319
|
-
'transfer-encoding', 'te', 'trailer', 'proxy-authenticate',
|
|
320
|
-
'proxy-authorization', 'content-encoding', 'content-length',
|
|
321
|
-
]);
|
|
445
|
+
// body was re-buffered, so let Node recompute it. (Shared set above.)
|
|
322
446
|
up.headers.forEach((v, k) => { if (!HOP_BY_HOP.has(k.toLowerCase())) h[k] = v; });
|
|
323
447
|
res.writeHead(up.status, h);
|
|
324
448
|
res.end(buf);
|
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",
|