feat: Add Vercel deployment protection bypass support, enhance related error handling, and update documentation.

This commit is contained in:
CJACK
2026-02-16 22:06:41 +08:00
parent d70a0acaa8
commit eb470c33ba
4 changed files with 56 additions and 1 deletions

View File

@@ -77,3 +77,9 @@ DS2API_ADMIN_KEY=admin
# VERCEL_TOKEN=your-vercel-token
# VERCEL_PROJECT_ID=prj_xxxxxxxxxxxx
# VERCEL_TEAM_ID=team_xxxxxxxxxxxx
# Optional: Vercel deployment protection bypass secret.
# If deployment protection is enabled, DS2API will use this value as
# x-vercel-protection-bypass for internal Node->Go calls on Vercel.
# You can also use VERCEL_AUTOMATION_BYPASS_SECRET directly.
# DS2API_VERCEL_PROTECTION_BYPASS=your-bypass-secret

View File

@@ -152,6 +152,12 @@ Vercel is validating frontend output against `public`. This repo builds WebUI in
If you manually changed Output Directory in Project Settings, set it to `static` (or clear it and let repo config apply).
If API responses return Vercel HTML `Authentication Required` (instead of JSON), the request is blocked by Vercel Deployment Protection:
- Disable protection for that deployment/environment (recommended for public API use)
- Or send `x-vercel-protection-bypass` in requests
- If only internal Node->Go calls are blocked, set `VERCEL_AUTOMATION_BYPASS_SECRET` (or `DS2API_VERCEL_PROTECTION_BYPASS`)
Vercel streaming note (important):
- Vercel Go Runtime applies platform-level buffering, so this repo uses a hybrid path on Vercel (`Go prepare + Node stream`) to restore real-time SSE behavior.

View File

@@ -150,6 +150,12 @@ No Output Directory named "public" found after the Build completed.
若你在项目设置里手动改过 Output Directory请同步改为 `static` 或清空让仓库配置生效。
若接口返回 Vercel 的 HTML 页面 `Authentication Required`(而不是 JSON说明被 Vercel Deployment Protection 拦截:
- 关闭该部署/环境的 Protection推荐用于公开 API
- 或给请求加 `x-vercel-protection-bypass`
- 若仅是 Vercel 内部 Node->Go 调用被拦截,可设置 `VERCEL_AUTOMATION_BYPASS_SECRET`(或 `DS2API_VERCEL_PROTECTION_BYPASS`
Vercel 流式说明(重要):
- Vercel 的 Go Runtime 存在平台层响应缓冲,因此本项目在 Vercel 上采用“Go prepare + Node stream”的混合链路来恢复实时 SSE。

View File

@@ -205,7 +205,10 @@ function setCorsHeaders(res) {
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Credentials', 'true');
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS, PUT, DELETE');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-API-Key, X-Ds2-Target-Account');
res.setHeader(
'Access-Control-Allow-Headers',
'Content-Type, Authorization, X-API-Key, X-Ds2-Target-Account, X-Vercel-Protection-Bypass',
);
}
function header(req, key) {
@@ -238,6 +241,10 @@ async function fetchStreamPrepare(req, rawBody) {
const url = new URL(`${proto}://${host}${req.url || '/v1/chat/completions'}`);
url.searchParams.set('__go', '1');
url.searchParams.set('__stream_prepare', '1');
const protectionBypass = resolveProtectionBypass(req);
if (protectionBypass) {
url.searchParams.set('x-vercel-protection-bypass', protectionBypass);
}
const upstream = await fetch(url.toString(), {
method: 'POST',
@@ -246,6 +253,7 @@ async function fetchStreamPrepare(req, rawBody) {
'x-api-key': asString(header(req, 'x-api-key')),
'x-ds2-target-account': asString(header(req, 'x-ds2-target-account')),
'x-ds2-internal-token': internalSecret(),
'x-vercel-protection-bypass': protectionBypass,
'content-type': asString(header(req, 'content-type')) || 'application/json',
},
body: rawBody,
@@ -269,6 +277,14 @@ async function fetchStreamPrepare(req, rawBody) {
}
function relayPreparedFailure(res, prep) {
if (prep.status === 401 && looksLikeVercelAuthPage(prep.text)) {
writeOpenAIError(
res,
401,
'Vercel Deployment Protection blocked internal prepare request. Disable protection for this deployment or set VERCEL_AUTOMATION_BYPASS_SECRET.',
);
return;
}
res.statusCode = prep.status || 500;
res.setHeader('Content-Type', prep.contentType || 'application/json');
if (prep.text) {
@@ -294,6 +310,22 @@ function internalSecret() {
return asString(process.env.DS2API_VERCEL_INTERNAL_SECRET) || asString(process.env.DS2API_ADMIN_KEY) || 'admin';
}
function resolveProtectionBypass(req) {
const fromHeader = asString(header(req, 'x-vercel-protection-bypass'));
if (fromHeader) {
return fromHeader;
}
return asString(process.env.VERCEL_AUTOMATION_BYPASS_SECRET) || asString(process.env.DS2API_VERCEL_PROTECTION_BYPASS);
}
function looksLikeVercelAuthPage(text) {
const body = asString(text).toLowerCase();
if (!body) {
return false;
}
return body.includes('authentication required') && body.includes('vercel');
}
function parseChunkForContent(chunk, thinkingEnabled, currentType) {
if (!chunk || typeof chunk !== 'object' || !Object.prototype.hasOwnProperty.call(chunk, 'v')) {
return { parts: [], finished: false, newType: currentType };
@@ -472,6 +504,10 @@ async function proxyToGo(req, res, rawBody) {
const host = asString(header(req, 'host'));
const url = new URL(`${proto}://${host}${req.url || '/v1/chat/completions'}`);
url.searchParams.set('__go', '1');
const protectionBypass = resolveProtectionBypass(req);
if (protectionBypass) {
url.searchParams.set('x-vercel-protection-bypass', protectionBypass);
}
const upstream = await fetch(url.toString(), {
method: 'POST',
@@ -479,6 +515,7 @@ async function proxyToGo(req, res, rawBody) {
authorization: asString(header(req, 'authorization')),
'x-api-key': asString(header(req, 'x-api-key')),
'x-ds2-target-account': asString(header(req, 'x-ds2-target-account')),
'x-vercel-protection-bypass': protectionBypass,
'content-type': asString(header(req, 'content-type')) || 'application/json',
},
body: rawBody,