diff --git a/.env.example b/.env.example index a4f392e..ddb7fef 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/DEPLOY.en.md b/DEPLOY.en.md index 3d09833..9c23c0f 100644 --- a/DEPLOY.en.md +++ b/DEPLOY.en.md @@ -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. diff --git a/DEPLOY.md b/DEPLOY.md index e443cb5..109f7fd 100644 --- a/DEPLOY.md +++ b/DEPLOY.md @@ -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。 diff --git a/api/chat-stream.js b/api/chat-stream.js index 4cf05da..2742d2f 100644 --- a/api/chat-stream.js +++ b/api/chat-stream.js @@ -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,