From 405f9680990a0932ac8fd408a0a86c7fefbbde23 Mon Sep 17 00:00:00 2001 From: wthsama Date: Mon, 28 Sep 2026 00:29:05 +0800 Subject: [PATCH 1/3] =?UTF-8?q?refactor(http):=20=E5=90=88=E5=B9=B6?= =?UTF-8?q?=E4=B8=89=E5=A4=84=E9=87=8D=E5=A4=8D=E7=9A=84=20HTTP=20?= =?UTF-8?q?=E5=B0=81=E8=A3=85=E4=B8=BA=E7=BB=9F=E4=B8=80=20fetch=20?= =?UTF-8?q?=E5=AE=A2=E6=88=B7=E7=AB=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit auth.js / models.js / cli-login.js 各自拷贝了一份 https.request 封装(代理、 超时、JSON 解析),proxy.js 另有一套。现合并为 src/http.js:基于 undici 的 fetch,按需注入代理 dispatcher,提供 request()(读完响应体)与 rawFetch() (保留响应流,用于 SSE 转发)。依赖 https-proxy-agent 换成 undici。 验证:node:22-alpine 下 npm install 后运行 test.js,覆盖普通请求 / 404 / 超时 / 无代理 dispatcher / 首页与 /health、/status、/fast,以及 /v1/responses 的 SSE 流式转发,全部通过。 --- package-lock.json | 106 +++++++++----------------------- package.json | 2 +- src/auth.js | 47 ++------------ src/cli-login.js | 37 ++--------- src/http.js | 46 ++++++++++++++ src/models.js | 52 +++++----------- src/proxy.js | 152 ++++++++++++++++++++-------------------------- src/settings.js | 20 +++--- 8 files changed, 175 insertions(+), 287 deletions(-) create mode 100644 src/http.js diff --git a/package-lock.json b/package-lock.json index 762e018..257050d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "1.0.0", "dependencies": { "express": "^4.21.0", - "https-proxy-agent": "^9.1.0" + "undici": "^6.21.0" } }, "node_modules/accepts": { @@ -25,15 +25,6 @@ "node": ">= 0.6" } }, - "node_modules/agent-base": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-9.0.0.tgz", - "integrity": "sha512-TQf59BsZnytt8GdJKLPfUZ54g/iaUL2OWDSFCCvMOhsHduDQxO8xC4PNeyIkVcA5KwL2phPSv0douC0fgWzmnA==", - "license": "MIT", - "engines": { - "node": ">= 20" - } - }, "node_modules/array-flatten": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", @@ -41,9 +32,9 @@ "license": "MIT" }, "node_modules/body-parser": { - "version": "1.20.6", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.6.tgz", - "integrity": "sha512-p5tAzS57i5MV9fZFDj9LeIiTZEufbSe2eDozP+ElheSUq1m74CRq1jI4mYNDdVs9vQztXFLuk/Gd6BWTdwRJ5g==", + "version": "1.20.8", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.8.tgz", + "integrity": "sha512-JNcyFQ64OiijEkPzUBTCe+hyPXUD/3LEldGQ6iF5LR1w00mx9o7xtDWHXBY2iItjdCFGoilOLNQbH943ut7pHA==", "license": "MIT", "dependencies": { "bytes": "~3.1.2", @@ -54,7 +45,7 @@ "http-errors": "~2.0.1", "iconv-lite": "~0.4.24", "on-finished": "~2.4.1", - "qs": "~6.15.1", + "qs": "~6.16.0", "raw-body": "~2.5.3", "type-is": "~1.6.18", "unpipe": "~1.0.0" @@ -241,9 +232,9 @@ } }, "node_modules/express": { - "version": "4.22.2", - "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz", - "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==", + "version": "4.22.3", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.3.tgz", + "integrity": "sha512-Bdcs4+3qlpVlx2NRn6fgX2Ue2/gGRaPeawebgclM0ERSCqDpA+owF1fdPwjJUTAJWMTuAaxjDf+hzb0/4eKvvw==", "license": "MIT", "dependencies": { "accepts": "~1.3.8", @@ -265,9 +256,9 @@ "methods": "~1.1.2", "on-finished": "~2.4.1", "parseurl": "~1.3.3", - "path-to-regexp": "~0.1.12", + "path-to-regexp": "~0.1.13", "proxy-addr": "~2.0.7", - "qs": "~6.15.1", + "qs": "~6.16.0", "range-parser": "~1.2.1", "safe-buffer": "5.2.1", "send": "~0.19.0", @@ -424,43 +415,6 @@ "url": "https://opencollective.com/express" } }, - "node_modules/https-proxy-agent": { - "version": "9.1.0", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-9.1.0.tgz", - "integrity": "sha512-ag87y7cJJ9/3+GxFr8Oy4O5faDsGRGnBGsJj/YjOSsSx/5eadKLYTMPlzuR6obgoCDDm0abAAZitXXQkMOPSpA==", - "license": "MIT", - "dependencies": { - "agent-base": "9.0.0", - "debug": "^4.3.4", - "proxy-agent-negotiate": "1.1.0" - }, - "engines": { - "node": ">= 20" - } - }, - "node_modules/https-proxy-agent/node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/https-proxy-agent/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT" - }, "node_modules/iconv-lite": { "version": "0.4.24", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", @@ -612,9 +566,9 @@ "license": "MIT" }, "node_modules/proxy-addr": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", - "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz", + "integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==", "license": "MIT", "dependencies": { "forwarded": "0.2.0", @@ -622,29 +576,16 @@ }, "engines": { "node": ">= 0.10" - } - }, - "node_modules/proxy-agent-negotiate": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/proxy-agent-negotiate/-/proxy-agent-negotiate-1.1.0.tgz", - "integrity": "sha512-N8IBcM3UgCVzz2L2Lqv8DVntDnnC8/hiV4nEDUPkqq72TPUgYWjQc+bdZlBPZK9LzPAvOY//gAt0S0DApoOXWQ==", - "license": "MIT", - "engines": { - "node": ">= 20" }, - "peerDependencies": { - "kerberos": "^2.0.0" - }, - "peerDependenciesMeta": { - "kerberos": { - "optional": true - } + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/qs": { - "version": "6.15.3", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", - "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", + "version": "6.16.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz", + "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==", "license": "BSD-3-Clause", "dependencies": { "es-define-property": "^1.0.1", @@ -861,6 +802,15 @@ "node": ">= 0.6" } }, + "node_modules/undici": { + "version": "6.29.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.29.0.tgz", + "integrity": "sha512-R+RODBqp6i2pPflGdq+xIOUkl+RNfGgHwoinecKu/JCuf2uO06cOKoDbI2P7Dn6KcswdKwrczbU6IYJ6K8X+wg==", + "license": "MIT", + "engines": { + "node": ">=18.17" + } + }, "node_modules/unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", diff --git a/package.json b/package.json index bf627e3..8f64e94 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,6 @@ }, "dependencies": { "express": "^4.21.0", - "https-proxy-agent": "^9.1.0" + "undici": "^6.21.0" } } diff --git a/src/auth.js b/src/auth.js index e32ce32..972930d 100644 --- a/src/auth.js +++ b/src/auth.js @@ -1,8 +1,7 @@ const fs = require("fs"); const path = require("path"); -const https = require("https"); const { exec } = require("child_process"); -const { getProxyAgent } = require("./settings"); +const { request } = require("./http"); const CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann"; const DEVICE_CODE_URL = "https://auth.openai.com/api/accounts/deviceauth/usercode"; @@ -10,40 +9,6 @@ const DEVICE_TOKEN_URL = "https://auth.openai.com/api/accounts/deviceauth/token" const TOKEN_URL = "https://auth.openai.com/oauth/token"; const DATA_FILE = path.join(__dirname, "..", "data", "auth.json"); -function httpsFetch(url, opts = {}) { - return new Promise((resolve, reject) => { - const u = new URL(url); - const agent = getProxyAgent(); - let body = ""; - const req = https.request({ - hostname: u.hostname, - port: u.port || 443, - path: u.pathname + u.search, - method: opts.method || "GET", - headers: { ...(opts.headers || {}), "Host": u.hostname }, - timeout: 30000, - agent, - servername: u.hostname, - }, (res) => { - res.on("data", (chunk) => (body += chunk)); - res.on("end", () => { - const ok = res.statusCode >= 200 && res.statusCode < 400; - const status = res.statusCode; - resolve({ - ok, - status, - text: () => body, - json: () => JSON.parse(body), - }); - }); - }); - req.on("error", reject); - req.on("timeout", () => { req.destroy(); reject(new Error("Request timeout")); }); - if (opts.body) req.write(opts.body); - req.end(); - }); -} - function ensureDataDir() { const dir = path.dirname(DATA_FILE); if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); @@ -69,7 +34,7 @@ function parseJwtPayload(token) { } async function startDeviceFlow() { - const resp = await httpsFetch(DEVICE_CODE_URL, { + const resp = await request(DEVICE_CODE_URL, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ client_id: CLIENT_ID, scope: "openid profile email" }), @@ -83,7 +48,7 @@ async function startDeviceFlow() { async function pollForToken(device) { for (let i = 0; i < 60; i++) { await sleep(5000); - const resp = await httpsFetch(DEVICE_TOKEN_URL, { + const resp = await request(DEVICE_TOKEN_URL, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ @@ -106,7 +71,7 @@ async function pollForToken(device) { } async function exchangeCodeForTokens(code, codeVerifier) { - const resp = await httpsFetch(TOKEN_URL, { + const resp = await request(TOKEN_URL, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ @@ -124,7 +89,7 @@ async function exchangeCodeForTokens(code, codeVerifier) { } async function refreshAccessToken(refreshToken) { - const resp = await httpsFetch(TOKEN_URL, { + const resp = await request(TOKEN_URL, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded", "User-Agent": "opencode-codex-bridge" }, body: new URLSearchParams({ @@ -212,7 +177,7 @@ async function startLoginSession() { await sleep(5000); if (session.aborted) return; - const resp = await httpsFetch(DEVICE_TOKEN_URL, { + const resp = await request(DEVICE_TOKEN_URL, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ diff --git a/src/cli-login.js b/src/cli-login.js index 898d2e6..aa9a968 100644 --- a/src/cli-login.js +++ b/src/cli-login.js @@ -1,36 +1,7 @@ -const https = require("https"); -const { getProxyAgent } = require("./settings"); +const { request } = require("./http"); const CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann"; -function fetch(url, opts) { - return new Promise((resolve, reject) => { - const u = new URL(url); - let body = ""; - const agent = getProxyAgent(); - const req = https.request({ - hostname: u.hostname, - port: u.port || 443, - path: u.pathname + u.search, - method: opts?.method || "GET", - headers: { ...(opts?.headers || {}), "Host": u.hostname }, - timeout: 30000, - agent, - servername: u.hostname, - }, (res) => { - res.on("data", (chunk) => (body += chunk)); - res.on("end", () => resolve({ ok: res.statusCode < 400, status: res.statusCode, text, json })); - }); - req.on("error", reject); - req.on("timeout", () => { req.destroy(); reject(new Error("Request timeout")); }); - if (opts?.body) req.write(opts.body); - req.end(); - - function text() { return body; } - function json() { return JSON.parse(body); } - }); -} - async function delay(ms) { return new Promise((r) => setTimeout(r, ms)); } @@ -38,7 +9,7 @@ async function delay(ms) { async function login() { console.log("Starting Codex OAuth login...\n"); - const deviceResp = await fetch("https://auth.openai.com/api/accounts/deviceauth/usercode", { + const deviceResp = await request("https://auth.openai.com/api/accounts/deviceauth/usercode", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ client_id: CLIENT_ID, scope: "openid profile email" }), @@ -57,7 +28,7 @@ async function login() { for (let i = 0; i < 60; i++) { await delay(5000); process.stdout.write("."); - const pollResp = await fetch("https://auth.openai.com/api/accounts/deviceauth/token", { + const pollResp = await request("https://auth.openai.com/api/accounts/deviceauth/token", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ @@ -85,7 +56,7 @@ async function login() { } console.log("\nAuthorized!"); - const exchangeResp = await fetch("https://auth.openai.com/oauth/token", { + const exchangeResp = await request("https://auth.openai.com/oauth/token", { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ diff --git a/src/http.js b/src/http.js new file mode 100644 index 0000000..af46319 --- /dev/null +++ b/src/http.js @@ -0,0 +1,46 @@ +// 统一的 HTTP 客户端:基于 undici 的 fetch,按需通过代理(dispatcher)出网。 +// 此前 auth.js / models.js / cli-login.js 各自拷贝了一份 https.request 封装, +// 现合并为这一份,避免重复实现请求、超时与 JSON 解析。 +const { fetch } = require("undici"); +const { getProxyDispatcher } = require("./settings"); + +// 底层请求:返回原始 Response,供需要读取流(SSE)的调用方使用。 +// timeout 只约束「拿到响应头」的耗时;流式响应体由调用方自行处理。 +async function rawFetch(url, { method = "GET", headers = {}, body, timeout = 60000, signal } = {}) { + const controller = signal ? null : new AbortController(); + const effectiveSignal = signal || controller.signal; + const timer = !signal && timeout > 0 + ? setTimeout(() => controller.abort(), timeout) + : null; + try { + return await fetch(url, { + method, + headers, + body, + signal: effectiveSignal, + dispatcher: getProxyDispatcher(), + }); + } catch (err) { + if (err && (err.name === "AbortError" || err.name === "TimeoutError")) { + throw new Error("Request timeout"); + } + throw err; + } finally { + if (timer) clearTimeout(timer); + } +} + +// 常规请求:读完响应体,返回与旧 https 封装一致的 { ok, status, text(), json() } 形状。 +async function request(url, opts = {}) { + const res = await rawFetch(url, opts); + const text = await res.text(); + return { + ok: res.status >= 200 && res.status < 400, + status: res.status, + headers: res.headers, + text: () => text, + json: () => JSON.parse(text), + }; +} + +module.exports = { request, rawFetch }; diff --git a/src/models.js b/src/models.js index 5a31b76..5d7cddd 100644 --- a/src/models.js +++ b/src/models.js @@ -1,42 +1,16 @@ -const https = require("https"); -const { getProxyAgent } = require("./settings"); +const { request } = require("./http"); const CODEX_MODELS_URL = "https://chatgpt.com/backend-api/codex/models"; const CODEX_USAGE_URL = "https://chatgpt.com/backend-api/wham/usage"; const CLIENT_VERSION = "1.0.0"; -function httpsGet(url, headers) { - return new Promise((resolve, reject) => { - const u = new URL(url); - const agent = getProxyAgent(); - let body = ""; - const req = https.request({ - hostname: u.hostname, - port: u.port || 443, - path: u.pathname + u.search, - method: "GET", - headers: { ...headers, "Host": u.hostname }, - timeout: 30000, - agent, - servername: u.hostname, - }, (res) => { - res.on("data", (chunk) => (body += chunk)); - res.on("end", () => { - const ok = res.statusCode >= 200 && res.statusCode < 400; - resolve({ ok, status: res.statusCode, text: () => body, json: () => JSON.parse(body) }); - }); - }); - req.on("error", reject); - req.on("timeout", () => { req.destroy(); reject(new Error("Request timeout")); }); - req.end(); - }); -} - async function fetchModels(accessToken, accountId) { - const resp = await httpsGet(`${CODEX_MODELS_URL}?client_version=${CLIENT_VERSION}`, { - Authorization: `Bearer ${accessToken}`, - "ChatGPT-Account-Id": accountId, - originator: "opencode-codex-bridge", + const resp = await request(`${CODEX_MODELS_URL}?client_version=${CLIENT_VERSION}`, { + headers: { + Authorization: `Bearer ${accessToken}`, + "ChatGPT-Account-Id": accountId, + originator: "opencode-codex-bridge", + }, }); if (!resp.ok) { throw new Error(`Failed to fetch models: ${resp.status} ${resp.text()}`); @@ -45,11 +19,13 @@ async function fetchModels(accessToken, accountId) { } async function fetchQuota(accessToken, accountId) { - const resp = await httpsGet(CODEX_USAGE_URL, { - Authorization: `Bearer ${accessToken}`, - "ChatGPT-Account-Id": accountId, - Accept: "application/json", - originator: "opencode-codex-bridge", + const resp = await request(CODEX_USAGE_URL, { + headers: { + Authorization: `Bearer ${accessToken}`, + "ChatGPT-Account-Id": accountId, + Accept: "application/json", + originator: "opencode-codex-bridge", + }, }); if (!resp.ok) { throw new Error(`Failed to fetch quota: ${resp.status} ${resp.text()}`); diff --git a/src/proxy.js b/src/proxy.js index 0991b79..facad5d 100644 --- a/src/proxy.js +++ b/src/proxy.js @@ -1,7 +1,10 @@ -const https = require("https"); -const { getFastMode, getProxyAgent } = require("./settings"); +const { Readable } = require("stream"); +const { getFastMode } = require("./settings"); +const { rawFetch } = require("./http"); -const CODEX_RESPONSES_URL = "https://chatgpt.com/backend-api/codex/responses"; +const CODEX_RESPONSES_URL = process.env.CODEX_RESPONSES_URL || "https://chatgpt.com/backend-api/codex/responses"; +const MAX_RETRIES = 5; +const STREAM_TIMEOUT_MS = 600000; function normalizeBody(body) { const b = { ...body }; @@ -18,43 +21,40 @@ function normalizeBody(body) { return b; } -function getHeader(map, name) { - const key = name.toLowerCase(); - for (const k of Object.keys(map)) { - if (k.toLowerCase() === key) return map[k]; - } - return undefined; +// fetch 的网络错误统一表现为 "fetch failed",真正原因在 cause 上。 +function errorText(err) { + const cause = err && err.cause; + return [err && err.message, cause && cause.message, cause && cause.code] + .filter(Boolean) + .join(" "); } -function isRetryableError(msg) { - return /socket disconnected before secure TLS/i.test(msg) - || /ECONNRESET/i.test(msg) - || /socket hang up/i.test(msg) - || /ETIMEDOUT/i.test(msg); +function isRetryableError(text) { + return /socket disconnected before secure TLS/i.test(text) + || /ECONNRESET/i.test(text) + || /socket hang up/i.test(text) + || /ETIMEDOUT/i.test(text) + || /timeout/i.test(text); } -async function doRequest(options, bodyStr) { - return new Promise((resolve, reject) => { - const upstream = https.request(options, (upRes) => { - resolve(upRes); - }); - upstream.on("error", reject); - upstream.on("timeout", () => { upstream.destroy(); reject(new Error("Request timeout")); }); - upstream.write(bodyStr); - upstream.end(); - }); +function sleep(ms) { + return new Promise((r) => setTimeout(r, ms)); } -async function forwardWithRetry(options, bodyStr, maxRetries = 5) { +async function requestUpstream(headers, bodyStr) { let lastError; - for (let i = 0; i <= maxRetries; i++) { + for (let i = 0; i <= MAX_RETRIES; i++) { try { - return await doRequest(options, bodyStr); + return await rawFetch(CODEX_RESPONSES_URL, { + method: "POST", + headers, + body: bodyStr, + timeout: STREAM_TIMEOUT_MS, + }); } catch (e) { lastError = e; - if (i < maxRetries && isRetryableError(e.message)) { - const delay = Math.min(1000 * Math.pow(2, i), 10000); - await new Promise(r => setTimeout(r, delay)); + if (i < MAX_RETRIES && isRetryableError(errorText(e))) { + await sleep(Math.min(1000 * Math.pow(2, i), 10000)); continue; } throw e; @@ -66,67 +66,45 @@ async function forwardWithRetry(options, bodyStr, maxRetries = 5) { async function proxyResponses(req, res, accessToken, accountId) { const normalizedBody = normalizeBody(req.body); const bodyStr = JSON.stringify(normalizedBody); + const headers = { + Authorization: `Bearer ${accessToken}`, + "ChatGPT-Account-Id": accountId, + originator: "opencode-codex-bridge", + "Content-Type": "application/json", + }; - return new Promise(async (resolve) => { - const u = new URL(CODEX_RESPONSES_URL); - const agent = getProxyAgent(); - const options = { - hostname: u.hostname, - port: u.port || 443, - path: u.pathname + u.search, - method: "POST", - headers: { - Authorization: `Bearer ${accessToken}`, - "ChatGPT-Account-Id": accountId, - originator: "opencode-codex-bridge", - "Content-Type": "application/json", - "Content-Length": Buffer.byteLength(bodyStr), - Host: u.hostname, - }, - timeout: 600000, - agent, - servername: u.hostname, - }; - - let upRes; - try { - upRes = await forwardWithRetry(options, bodyStr); - } catch (e) { - if (!res.headersSent) res.status(502).json({ error: { message: `Upstream error: ${e.message}` } }); - return resolve(); - } + let upRes; + try { + upRes = await requestUpstream(headers, bodyStr); + } catch (e) { + if (!res.headersSent) res.status(502).json({ error: { message: `Upstream error: ${e.message}` } }); + return; + } - const status = upRes.statusCode; - const contentType = getHeader(upRes.headers, "content-type") || ""; + const status = upRes.status; + const contentType = upRes.headers.get("content-type") || ""; - if (status >= 400) { - let buf = ""; - upRes.on("data", (chunk) => (buf += chunk)); - upRes.on("end", () => { - res.status(status).set("Content-Type", "application/json"); - try { res.send(JSON.stringify(JSON.parse(buf))); } catch { res.send(buf); } - resolve(); - }); - return; - } + if (status >= 400) { + const text = await upRes.text(); + res.status(status).set("Content-Type", "application/json"); + try { res.send(JSON.stringify(JSON.parse(text))); } catch { res.send(text); } + return; + } - const isSse = contentType.includes("text/event-stream") || normalizedBody.stream; - if (isSse) { - res.setHeader("Content-Type", "text/event-stream"); - res.setHeader("Cache-Control", "no-cache"); - res.setHeader("Connection", "keep-alive"); - res.flushHeaders(); - upRes.pipe(res); - upRes.on("end", () => resolve()); - } else { - let buf = ""; - upRes.on("data", (chunk) => (buf += chunk)); - upRes.on("end", () => { - res.set("Content-Type", "application/json").send(buf); - resolve(); - }); - } - }); + const isSse = contentType.includes("text/event-stream") || normalizedBody.stream; + if (isSse) { + res.setHeader("Content-Type", "text/event-stream"); + res.setHeader("Cache-Control", "no-cache"); + res.setHeader("Connection", "keep-alive"); + res.flushHeaders(); + const upstreamStream = Readable.fromWeb(upRes.body); + upstreamStream.on("error", (err) => { res.destroy(err); }); + res.on("close", () => upstreamStream.destroy()); + upstreamStream.pipe(res); + } else { + const text = await upRes.text(); + res.set("Content-Type", "application/json").send(text); + } } module.exports = { proxyResponses }; diff --git a/src/settings.js b/src/settings.js index 58c393e..79ae8e7 100644 --- a/src/settings.js +++ b/src/settings.js @@ -1,10 +1,10 @@ const fs = require("fs"); const path = require("path"); -const { HttpsProxyAgent } = require("https-proxy-agent"); +const { ProxyAgent } = require("undici"); const SETTINGS_FILE = path.join(__dirname, "..", "data", "settings.json"); -let cachedAgent = undefined; +let cachedDispatcher = undefined; let cachedProxyUrl = undefined; function readSettings() { @@ -45,24 +45,26 @@ function setProxyConfig(proxyUrl) { const s = readSettings(); s.proxy = (proxyUrl || "").trim(); writeSettings(s); - cachedAgent = undefined; + cachedDispatcher = undefined; cachedProxyUrl = undefined; } -function getProxyAgent() { +function getProxyDispatcher() { const proxyUrl = getActiveProxyUrl(); if (!proxyUrl) { return undefined; // direct connection } - if (cachedAgent && cachedProxyUrl === proxyUrl) { - return cachedAgent; + if (cachedDispatcher && cachedProxyUrl === proxyUrl) { + return cachedDispatcher; } try { cachedProxyUrl = proxyUrl; - cachedAgent = new HttpsProxyAgent(proxyUrl); - return cachedAgent; + cachedDispatcher = new ProxyAgent(proxyUrl); + return cachedDispatcher; } catch (err) { console.error(`Invalid proxy URL "${proxyUrl}":`, err.message); + cachedDispatcher = undefined; + cachedProxyUrl = undefined; return undefined; } } @@ -92,7 +94,7 @@ module.exports = { getCustomProxy, setCustomProxy, getActiveProxyUrl, - getProxyAgent, + getProxyDispatcher, readSettings, }; From b6501dc17e87213c47a170b365b935d7c7462a7b Mon Sep 17 00:00:00 2001 From: wthsama Date: Mon, 28 Sep 2026 02:47:13 +0800 Subject: [PATCH 2/3] =?UTF-8?q?test:=20=E6=B7=BB=E5=8A=A0=E7=83=9F?= =?UTF-8?q?=E9=9B=BE=E6=B5=8B=E8=AF=95=E5=B9=B6=E6=8E=A5=E5=85=A5=20npm=20?= =?UTF-8?q?test?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 test/smoke.js(零依赖断言,失败即非零退出),覆盖 src/http 客户端 (正常/404/超时/无代理)、app 路由(/health、/status、/fast、首页)以及 /v1/responses 的 SSE 流式转发;package.json 增加 test 脚本与 engines。 --- package.json | 6 +++- test/smoke.js | 85 +++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 90 insertions(+), 1 deletion(-) create mode 100644 test/smoke.js diff --git a/package.json b/package.json index 8f64e94..3591551 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,11 @@ "main": "src/index.js", "scripts": { "start": "node src/index.js", - "login": "node src/cli-login.js" + "login": "node src/cli-login.js", + "test": "node test/smoke.js" + }, + "engines": { + "node": ">=18" }, "dependencies": { "express": "^4.21.0", diff --git a/test/smoke.js b/test/smoke.js new file mode 100644 index 0000000..18246b1 --- /dev/null +++ b/test/smoke.js @@ -0,0 +1,85 @@ +// 烟雾测试:不依赖任何测试框架,断言失败即非零退出。 +// 覆盖:HTTP 客户端(正常/404/超时/无代理)、app 路由、/v1/responses 的 SSE 转发。 +const http = require("http"); +const fs = require("fs"); +const path = require("path"); +const assert = require("assert"); + +const ROOT = path.join(__dirname, ".."); + +async function main() { + // 本地假上游 + const upstream = http.createServer((req, res) => { + if (req.url === "/slow") { setTimeout(() => { res.writeHead(200); res.end("late"); }, 500); return; } + if (req.url === "/sse") { + res.writeHead(200, { "content-type": "text/event-stream" }); + res.write('data: {"type":"response.output_text.delta","delta":"hello"}\n\n'); + res.write("data: [DONE]\n\n"); + res.end(); + return; + } + if (req.url === "/nope") { + res.writeHead(404, { "content-type": "application/json" }); + res.end(JSON.stringify({ error: "not found" })); + return; + } + let body = ""; + req.on("data", (c) => (body += c)); + req.on("end", () => { + res.writeHead(200, { "content-type": "application/json" }); + res.end(JSON.stringify({ method: req.method, body, url: req.url })); + }); + }); + await new Promise((r) => upstream.listen(0, "127.0.0.1", r)); + const up = `http://127.0.0.1:${upstream.address().port}`; + + const { request } = require(path.join(ROOT, "src", "http")); + + const r = await request(`${up}/echo`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ a: 1 }) }); + assert.strictEqual(r.ok, true); + assert.deepStrictEqual(r.json(), { method: "POST", body: '{"a":1}', url: "/echo" }); + + const nf = await request(`${up}/nope`); + assert.strictEqual(nf.ok, false); + assert.strictEqual(nf.status, 404); + + let timedOut = false; + try { await request(`${up}/slow`, { timeout: 50 }); } catch (e) { timedOut = /timeout/i.test(e.message); } + assert.strictEqual(timedOut, true, "应触发超时"); + + const settings = require(path.join(ROOT, "src", "settings")); + assert.strictEqual(settings.getProxyDispatcher(), undefined); + assert.strictEqual(settings.getActiveProxyUrl(), ""); + + // 造一份 auth,验证已登录路径 + const dataDir = path.join(ROOT, "data"); + fs.mkdirSync(dataDir, { recursive: true }); + fs.writeFileSync(path.join(dataDir, "auth.json"), JSON.stringify({ + account_id: "acct_test", email: "t@example.com", access_token: "tok_test", + refresh_token: "ref_test", expires_at: Date.now() + 3600 * 1000, + })); + process.env.CODEX_RESPONSES_URL = `${up}/sse`; + + const { createApp } = require(path.join(ROOT, "src", "index")); + const app = createApp(); + const appServer = app.listen(0, "127.0.0.1"); + await new Promise((r) => appServer.once("listening", r)); + const base = `http://127.0.0.1:${appServer.address().port}`; + + assert.deepStrictEqual((await request(`${base}/health`)).json(), { status: "ok" }); + assert.strictEqual((await request(`${base}/status`)).json().authenticated, true); + assert.strictEqual((await request(`${base}/fast`, { method: "POST" })).json().fast, true); + assert.strictEqual((await request(`${base}/fast`, { method: "POST" })).json().fast, false); + assert.ok((await request(`${base}/`)).text().includes("OpenCode Codex Bridge")); + + const sse = await request(`${base}/v1/responses`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ model: "gpt-5", input: "hi" }) }); + assert.strictEqual(sse.status, 200); + assert.ok(sse.text().includes("response.output_text.delta")); + assert.ok(sse.text().includes("[DONE]")); + + appServer.close(); + upstream.close(); + console.log("ALL TESTS PASSED"); +} + +main().catch((e) => { console.error("TEST FAILED:", e); process.exit(1); }); From f876d73360be913ad25c5779ee2149cb83a7dabe Mon Sep 17 00:00:00 2001 From: wthsama Date: Mon, 28 Sep 2026 02:47:13 +0800 Subject: [PATCH 3/3] =?UTF-8?q?ci:=20=E6=B7=BB=E5=8A=A0=20CI=20=E4=B8=8E?= =?UTF-8?q?=20release=20=E5=B7=A5=E4=BD=9C=E6=B5=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ci.yml:pull_request 与 main 推送时跑 npm ci + 语法检查 + npm test。 - release.yml:推 release 分支或 v*.*.* tag 时构建并推送 ghcr.io 镜像, 打 release/latest 与语义化版本 tag,供本地 docker pull。 --- .github/workflows/ci.yml | 36 ++++++++++++++++++ .github/workflows/release.yml | 70 +++++++++++++++++++++++++++++++++++ 2 files changed, 106 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..2cb012e --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,36 @@ +name: CI + +on: + pull_request: + push: + branches: [main] + +permissions: + contents: read + +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + test: + name: Node tests + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up Node + uses: actions/setup-node@v4 + with: + node-version: "22" + cache: npm + + - name: Install + run: npm ci + + - name: Syntax check + run: for f in src/*.js; do node --check "$f"; done + + - name: Test + run: npm test diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..f2c7248 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,70 @@ +name: Release image + +# 推 release 分支或打 v*.*.* tag 时构建镜像并推送到 GitHub Container Registry。 +# 本地拉取:docker pull ghcr.io/wthsama/opencode-codex-bridge:release +on: + push: + branches: [release] + tags: ["v*.*.*"] + +permissions: + contents: read + packages: write + +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false + +env: + IMAGE: ghcr.io/${{ github.repository }} + +jobs: + build: + name: Build and push + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up Node + uses: actions/setup-node@v4 + with: + node-version: "22" + cache: npm + + - name: Install + run: npm ci + + - name: Test + run: npm test + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Docker metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.IMAGE }} + tags: | + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=raw,value=release,enable=${{ github.ref == 'refs/heads/release' }} + type=raw,value=latest,enable=${{ github.ref == 'refs/heads/release' }} + + - name: Build and push + uses: docker/build-push-action@v6 + with: + context: . + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max