mirror of
https://github.com/Nezumi-2711/9router.git
synced 2026-09-22 13:38:31 +00:00
# v0.4.62 (2026-05-26)
## Fixes - Codex: auto-retry when upstream drops mid-stream (no more hangs) - Codex: fix random 400/404 errors, tool-calling failures, and unstable prompt cache - MITM: support Antigravity 2.x - Sanitize Read tool args to prevent retry loops from non-Anthropic models (#1144) - Implement json_schema fallback for OpenAI-compatible providers without native Structured Output (#1343) - Strip empty Read pages argument in OpenAI-to-Claude translator (#1354) - Forward Gemini output dimensions for embeddings (#1366) - Resolve setState-in-effect errors in dashboard components (#1362) - Gemini CLI: reuse stored OAuth project IDs for quota checks and show clearer setup guidance when the project is missing (#1271, #1428)
This commit is contained in:
+1
-1
@@ -3,7 +3,7 @@
|
|||||||
## Fixes
|
## Fixes
|
||||||
- Codex: auto-retry when upstream drops mid-stream (no more hangs)
|
- Codex: auto-retry when upstream drops mid-stream (no more hangs)
|
||||||
- Codex: fix random 400/404 errors, tool-calling failures, and unstable prompt cache
|
- Codex: fix random 400/404 errors, tool-calling failures, and unstable prompt cache
|
||||||
- MITM: support Antigravity 2.x (updated IDE version detection and DNS/cert flow)
|
- MITM: support Antigravity 2.x
|
||||||
- Sanitize Read tool args to prevent retry loops from non-Anthropic models (#1144)
|
- Sanitize Read tool args to prevent retry loops from non-Anthropic models (#1144)
|
||||||
- Implement json_schema fallback for OpenAI-compatible providers without native Structured Output (#1343)
|
- Implement json_schema fallback for OpenAI-compatible providers without native Structured Output (#1343)
|
||||||
- Strip empty Read pages argument in OpenAI-to-Claude translator (#1354)
|
- Strip empty Read pages argument in OpenAI-to-Claude translator (#1354)
|
||||||
|
|||||||
@@ -65,10 +65,7 @@ export function createSSEStream(options = {}) {
|
|||||||
|
|
||||||
return new TransformStream({
|
return new TransformStream({
|
||||||
transform(chunk, controller) {
|
transform(chunk, controller) {
|
||||||
if (!ttftAt) {
|
if (!ttftAt) ttftAt = Date.now();
|
||||||
ttftAt = Date.now();
|
|
||||||
dbg("SSE", `${provider}/${model} | first chunk received | size=${chunk?.byteLength || 0}B`);
|
|
||||||
}
|
|
||||||
const text = decoder.decode(chunk, { stream: true });
|
const text = decoder.decode(chunk, { stream: true });
|
||||||
buffer += text;
|
buffer += text;
|
||||||
reqLogger?.appendProviderChunk?.(text);
|
reqLogger?.appendProviderChunk?.(text);
|
||||||
@@ -83,7 +80,6 @@ export function createSSEStream(options = {}) {
|
|||||||
if (trimmed.startsWith("event:")) {
|
if (trimmed.startsWith("event:")) {
|
||||||
const evt = trimmed.slice(6).trim();
|
const evt = trimmed.slice(6).trim();
|
||||||
eventTypeCounts[evt] = (eventTypeCounts[evt] || 0) + 1;
|
eventTypeCounts[evt] = (eventTypeCounts[evt] || 0) + 1;
|
||||||
if (eventTypeCounts[evt] <= 2) dbg("SSE", `recv event: ${evt} (#${eventTypeCounts[evt]})`);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "9router-app",
|
"name": "9router-app",
|
||||||
"version": "0.4.59",
|
"version": "0.4.62",
|
||||||
"description": "9Router web dashboard",
|
"description": "9Router web dashboard",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ function applyAntigravityIdeVersionOverride(bodyBuffer, headers) {
|
|||||||
if (!shouldRewriteMetadata(parsed?.metadata)) {
|
if (!shouldRewriteMetadata(parsed?.metadata)) {
|
||||||
return { bodyBuffer, headers: nextHeaders, applied: userAgentChanged, version: ANTIGRAVITY_IDE_VERSION };
|
return { bodyBuffer, headers: nextHeaders, applied: userAgentChanged, version: ANTIGRAVITY_IDE_VERSION };
|
||||||
}
|
}
|
||||||
|
|
||||||
parsed.metadata.ideVersion = ANTIGRAVITY_IDE_VERSION;
|
parsed.metadata.ideVersion = ANTIGRAVITY_IDE_VERSION;
|
||||||
const nextBodyBuffer = Buffer.from(JSON.stringify(parsed));
|
const nextBodyBuffer = Buffer.from(JSON.stringify(parsed));
|
||||||
return { bodyBuffer: nextBodyBuffer, headers: nextHeaders, applied: true, version: ANTIGRAVITY_IDE_VERSION };
|
return { bodyBuffer: nextBodyBuffer, headers: nextHeaders, applied: true, version: ANTIGRAVITY_IDE_VERSION };
|
||||||
|
|||||||
+112
-2
@@ -1,4 +1,6 @@
|
|||||||
const https = require("https");
|
const https = require("https");
|
||||||
|
const http2 = require("http2");
|
||||||
|
const tls = require("tls");
|
||||||
const fs = require("fs");
|
const fs = require("fs");
|
||||||
const path = require("path");
|
const path = require("path");
|
||||||
const dns = require("dns");
|
const dns = require("dns");
|
||||||
@@ -133,7 +135,6 @@ async function passthrough(req, res, bodyBuffer, onResponse) {
|
|||||||
// Only rewrite host for chat endpoints — daily-cloudcode-pa rejects auth/login requests
|
// Only rewrite host for chat endpoints — daily-cloudcode-pa rejects auth/login requests
|
||||||
const isChatEndpoint = req.url.includes(":generateContent") || req.url.includes(":streamGenerateContent");
|
const isChatEndpoint = req.url.includes(":generateContent") || req.url.includes(":streamGenerateContent");
|
||||||
const targetHost = isChatEndpoint ? (HOST_REWRITE[originalHost] || originalHost) : originalHost;
|
const targetHost = isChatEndpoint ? (HOST_REWRITE[originalHost] || originalHost) : originalHost;
|
||||||
const targetIP = await resolveTargetIP(targetHost);
|
|
||||||
const dumper = ENABLE_FILE_LOG ? createResponseDumper(req, "passthrough") : null;
|
const dumper = ENABLE_FILE_LOG ? createResponseDumper(req, "passthrough") : null;
|
||||||
|
|
||||||
const tool = getToolForHost(req.headers.host);
|
const tool = getToolForHost(req.headers.host);
|
||||||
@@ -146,12 +147,121 @@ async function passthrough(req, res, bodyBuffer, onResponse) {
|
|||||||
headersForForwarding["content-length"] = String(bodyForForwarding.length);
|
headersForForwarding["content-length"] = String(bodyForForwarding.length);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ALPN negotiate: try HTTP/2 first (like browsers/mitmweb), fallback HTTP/1.1
|
||||||
|
try {
|
||||||
|
const proto = await negotiateAlpn(targetHost);
|
||||||
|
if (proto === "h2") {
|
||||||
|
return await passthroughHttp2(req, res, bodyForForwarding, headersForForwarding, targetHost, onResponse, dumper);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
err(`[mitm] ALPN negotiate failed: ${e.message}, fallback to HTTP/1.1`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return passthroughHttps(req, res, bodyForForwarding, headersForForwarding, targetHost, onResponse, dumper);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── ALPN negotiation cache ────────────────────────────────────
|
||||||
|
const alpnCache = new Map(); // host → "h2" | "http/1.1"
|
||||||
|
async function negotiateAlpn(host) {
|
||||||
|
if (alpnCache.has(host)) return alpnCache.get(host);
|
||||||
|
const ip = await resolveTargetIP(host);
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const socket = tls.connect({
|
||||||
|
host: ip, port: 443, servername: host,
|
||||||
|
ALPNProtocols: ["h2", "http/1.1"], rejectUnauthorized: false,
|
||||||
|
}, () => {
|
||||||
|
const proto = socket.alpnProtocol || "http/1.1";
|
||||||
|
alpnCache.set(host, proto);
|
||||||
|
log(`🔗 [mitm] ALPN ${host} → ${proto}`);
|
||||||
|
socket.end();
|
||||||
|
resolve(proto);
|
||||||
|
});
|
||||||
|
socket.once("error", reject);
|
||||||
|
socket.setTimeout(5000, () => { socket.destroy(new Error("ALPN timeout")); });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// HTTP/2 passthrough using node:http2 native
|
||||||
|
async function passthroughHttp2(req, res, bodyBuffer, headers, targetHost, onResponse, dumper) {
|
||||||
|
const targetIP = await resolveTargetIP(targetHost);
|
||||||
|
// HTTP/2 pseudo-headers required; strip HTTP/1.1-only headers
|
||||||
|
const h2Headers = {};
|
||||||
|
for (const [k, v] of Object.entries(headers)) {
|
||||||
|
const lk = k.toLowerCase();
|
||||||
|
if (lk === "host" || lk === "connection" || lk === "keep-alive" ||
|
||||||
|
lk === "transfer-encoding" || lk === "upgrade" || lk === "proxy-connection") continue;
|
||||||
|
h2Headers[lk] = v;
|
||||||
|
}
|
||||||
|
h2Headers[":method"] = req.method;
|
||||||
|
h2Headers[":path"] = req.url;
|
||||||
|
h2Headers[":scheme"] = "https";
|
||||||
|
h2Headers[":authority"] = targetHost;
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const client = http2.connect(`https://${targetHost}`, {
|
||||||
|
createConnection: () => tls.connect({
|
||||||
|
host: targetIP, port: 443, servername: targetHost,
|
||||||
|
ALPNProtocols: ["h2"], rejectUnauthorized: false,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
client.once("error", (e) => {
|
||||||
|
err(`[mitm] http2 client error: ${e.message}`);
|
||||||
|
if (dumper) { dumper.writeChunk(`\n[ERROR h2] ${e.message}\n`); dumper.end(); }
|
||||||
|
if (!res.headersSent) res.writeHead(502);
|
||||||
|
if (!res.writableEnded) res.end("Bad Gateway");
|
||||||
|
try { client.close(); } catch {}
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
const stream = client.request(h2Headers, { endStream: bodyBuffer.length === 0 });
|
||||||
|
if (bodyBuffer.length > 0) stream.end(bodyBuffer);
|
||||||
|
|
||||||
|
stream.once("response", (responseHeaders) => {
|
||||||
|
const status = responseHeaders[":status"];
|
||||||
|
// Filter pseudo-headers + connection-specific
|
||||||
|
const outHeaders = {};
|
||||||
|
for (const [k, v] of Object.entries(responseHeaders)) {
|
||||||
|
if (k.startsWith(":")) continue;
|
||||||
|
if (k === "connection" || k === "keep-alive" || k === "transfer-encoding") continue;
|
||||||
|
outHeaders[k] = v;
|
||||||
|
}
|
||||||
|
res.writeHead(status, outHeaders);
|
||||||
|
if (dumper) dumper.writeHeader(status, outHeaders);
|
||||||
|
|
||||||
|
const chunks = [];
|
||||||
|
stream.on("data", chunk => {
|
||||||
|
if (dumper) dumper.writeChunk(chunk);
|
||||||
|
if (onResponse) chunks.push(chunk);
|
||||||
|
res.write(chunk);
|
||||||
|
});
|
||||||
|
stream.on("end", () => {
|
||||||
|
if (dumper) dumper.end();
|
||||||
|
if (!res.writableEnded) res.end();
|
||||||
|
if (onResponse) try { onResponse(Buffer.concat(chunks), outHeaders); } catch {}
|
||||||
|
try { client.close(); } catch {}
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
stream.once("error", (e) => {
|
||||||
|
err(`[mitm] http2 stream error: ${e.message}`);
|
||||||
|
if (dumper) { dumper.writeChunk(`\n[ERROR h2-stream] ${e.message}\n`); dumper.end(); }
|
||||||
|
if (!res.headersSent) res.writeHead(502);
|
||||||
|
if (!res.writableEnded) res.end();
|
||||||
|
try { client.close(); } catch {}
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: raw https.request HTTP/1.1 with custom DNS (bypasses /etc/hosts MITM loop)
|
||||||
|
async function passthroughHttps(req, res, bodyBuffer, headers, targetHost, onResponse, dumper) {
|
||||||
|
const targetIP = await resolveTargetIP(targetHost);
|
||||||
const forwardReq = https.request({
|
const forwardReq = https.request({
|
||||||
hostname: targetIP,
|
hostname: targetIP,
|
||||||
port: 443,
|
port: 443,
|
||||||
path: req.url,
|
path: req.url,
|
||||||
method: req.method,
|
method: req.method,
|
||||||
headers: headersForForwarding,
|
headers,
|
||||||
servername: targetHost,
|
servername: targetHost,
|
||||||
rejectUnauthorized: false
|
rejectUnauthorized: false
|
||||||
}, (forwardRes) => {
|
}, (forwardRes) => {
|
||||||
|
|||||||
Vendored
+6
-1
@@ -1,7 +1,12 @@
|
|||||||
import initializeApp from "./initializeApp.js";
|
import initializeApp from "./initializeApp.js";
|
||||||
|
|
||||||
|
// Skip during Next.js build/prerender — bootstrap would download cloudflared, init DNS, etc.
|
||||||
|
const isBuildPhase = process.env.NEXT_PHASE === "phase-production-build"
|
||||||
|
|| process.env.NEXT_PHASE === "phase-export"
|
||||||
|
|| process.env.NEXT_PHASE === "phase-static";
|
||||||
|
|
||||||
// Server-only singleton: guard via global so HMR / re-imports don't double-init
|
// Server-only singleton: guard via global so HMR / re-imports don't double-init
|
||||||
if (typeof window === "undefined" && !global.__appBootstrapped) {
|
if (typeof window === "undefined" && !isBuildPhase && !global.__appBootstrapped) {
|
||||||
global.__appBootstrapped = true;
|
global.__appBootstrapped = true;
|
||||||
initializeApp().catch((e) => console.error("[Bootstrap] init failed:", e.message));
|
initializeApp().catch((e) => console.error("[Bootstrap] init failed:", e.message));
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user