From 89eb26dee2ea80b66d957a885c9ce51c330356e6 Mon Sep 17 00:00:00 2001 From: decolua Date: Mon, 13 Apr 2026 10:08:24 +0700 Subject: [PATCH] Enhance proxy functionality with Vercel relay support --- open-sse/config/providerModels.js | 2 +- open-sse/executors/cursor.js | 2 +- open-sse/handlers/chatCore.js | 7 +- open-sse/utils/proxyFetch.js | 12 ++ .../dashboard/providers/[id]/page.js | 5 +- .../providers/components/ModelsCard.js | 5 +- .../(dashboard)/dashboard/proxy-pools/page.js | 94 +++++++++++- src/app/api/providers/[id]/test/testUtils.js | 10 +- src/app/api/proxy-pools/[id]/route.js | 5 + src/app/api/proxy-pools/[id]/test/route.js | 35 ++++- src/app/api/proxy-pools/route.js | 5 +- .../api/proxy-pools/vercel-deploy/route.js | 142 ++++++++++++++++++ src/lib/localDb.js | 1 + src/lib/network/connectionProxy.js | 14 ++ src/sse/services/auth.js | 1 + 15 files changed, 331 insertions(+), 9 deletions(-) create mode 100644 src/app/api/proxy-pools/vercel-deploy/route.js diff --git a/open-sse/config/providerModels.js b/open-sse/config/providerModels.js index 008cce0..faca363 100644 --- a/open-sse/config/providerModels.js +++ b/open-sse/config/providerModels.js @@ -44,7 +44,7 @@ export const PROVIDER_MODELS = { { id: "qwen3-coder-plus", name: "Qwen3 Coder Plus" }, { id: "qwen3-coder-flash", name: "Qwen3 Coder Flash" }, { id: "vision-model", name: "Qwen3 Vision Model" }, - { id: "coder-model", name: "Qwen3.5 Coder Model" }, + { id: "coder-model", name: "Qwen3.6 Coder Model" }, ], if: [ // iFlow AI { id: "qwen3-coder-plus", name: "Qwen3 Coder Plus" }, diff --git a/open-sse/executors/cursor.js b/open-sse/executors/cursor.js index 18b9adf..2873782 100644 --- a/open-sse/executors/cursor.js +++ b/open-sse/executors/cursor.js @@ -215,7 +215,7 @@ export class CursorExecutor extends BaseExecutor { const transformedBody = this.transformRequest(model, body, stream, credentials); try { - const shouldForceFetch = proxyOptions?.enabled === true || proxyOptions?.connectionProxyEnabled === true; + const shouldForceFetch = proxyOptions?.enabled === true || proxyOptions?.connectionProxyEnabled === true || !!proxyOptions?.vercelRelayUrl; const response = (http2 && !shouldForceFetch) ? await this.makeHttp2Request(url, headers, transformedBody, signal) : await this.makeFetchRequest(url, headers, transformedBody, signal, proxyOptions); diff --git a/open-sse/handlers/chatCore.js b/open-sse/handlers/chatCore.js index fc564d3..0b3bcc1 100644 --- a/open-sse/handlers/chatCore.js +++ b/open-sse/handlers/chatCore.js @@ -98,9 +98,14 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred connectionProxyEnabled: credentials?.providerSpecificData?.connectionProxyEnabled === true, connectionProxyUrl: credentials?.providerSpecificData?.connectionProxyUrl || "", connectionNoProxy: credentials?.providerSpecificData?.connectionNoProxy || "", + vercelRelayUrl: credentials?.providerSpecificData?.vercelRelayUrl || "", }; - if (proxyOptions.connectionProxyEnabled && proxyOptions.connectionProxyUrl) { + if (proxyOptions.vercelRelayUrl) { + const connectionName = credentials?.connectionName || credentials?.connectionId || "unknown"; + const poolId = credentials?.providerSpecificData?.connectionProxyPoolId || "none"; + log?.info?.("PROXY", `${provider.toUpperCase()} | ${model} | conn=${connectionName} | pool=${poolId} | vercel-relay=${proxyOptions.vercelRelayUrl}`); + } else if (proxyOptions.connectionProxyEnabled && proxyOptions.connectionProxyUrl) { let maskedProxyUrl = proxyOptions.connectionProxyUrl; try { const parsed = new URL(proxyOptions.connectionProxyUrl); diff --git a/open-sse/utils/proxyFetch.js b/open-sse/utils/proxyFetch.js index 2e7b730..c60854c 100644 --- a/open-sse/utils/proxyFetch.js +++ b/open-sse/utils/proxyFetch.js @@ -198,6 +198,18 @@ async function createBypassRequest(parsedUrl, realIP, options) { export async function proxyAwareFetch(url, options = {}, proxyOptions = null) { const targetUrl = typeof url === "string" ? url : url.toString(); + // Vercel relay: forward request via relay headers + const vercelRelayUrl = normalizeString(proxyOptions?.vercelRelayUrl); + if (vercelRelayUrl) { + const parsed = new URL(targetUrl); + const relayHeaders = { + ...options.headers, + "x-relay-target": `${parsed.protocol}//${parsed.host}`, + "x-relay-path": `${parsed.pathname}${parsed.search}`, + }; + return originalFetch(vercelRelayUrl, { ...options, headers: relayHeaders }); + } + const connectionProxyUrl = resolveConnectionProxyUrl(targetUrl, proxyOptions); const envProxyUrl = connectionProxyUrl ? null : normalizeProxyUrl(getEnvProxyUrl(targetUrl)); const proxyUrl = connectionProxyUrl || envProxyUrl; diff --git a/src/app/(dashboard)/dashboard/providers/[id]/page.js b/src/app/(dashboard)/dashboard/providers/[id]/page.js index b5f1374..3adb0a6 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/page.js +++ b/src/app/(dashboard)/dashboard/providers/[id]/page.js @@ -1019,7 +1019,10 @@ function ModelRow({ model, fullModel, alias, copied, onCopy, testStatus, isCusto > {testStatus === "ok" ? "check_circle" : testStatus === "error" ? "cancel" : "smart_toy"} - {fullModel} +
+ {fullModel} + {model.name && {model.name}} +
{onTest && (
+
@@ -341,6 +382,9 @@ export default function ProxyPoolsPage() { {pool.isActive ? "active" : "inactive"} + {pool.type === "vercel" && ( + vercel relay + )} {pool.boundConnectionCount || 0} bound @@ -420,6 +464,54 @@ export default function ProxyPoolsPage() { + +
+
+

What is Vercel Relay?

+

+ Deploys an edge relay function to Vercel. All AI provider requests will be forwarded through Vercel's edge network, masking your real IP from providers. +

+
    +
  • Your IP is replaced by Vercel's dynamic edge IPs (hundreds of IPs across 20+ global regions)
  • +
  • Vercel serves millions of apps — providers can't block Vercel IPs without affecting legitimate traffic
  • +
  • Free tier: 100GB bandwidth/month, 500K edge invocations
  • +
  • Deploy multiple relays on different accounts for more IP diversity
  • +
+
+ setVercelForm((prev) => ({ ...prev, vercelToken: e.target.value }))} + placeholder="your-vercel-api-token" + hint={<>Token is used once for deployment and not stored. Get token →} + type="password" + /> + setVercelForm((prev) => ({ ...prev, projectName: e.target.value }))} + placeholder="my-relay" + hint="Unique name for your Vercel project. Leave empty for auto-generated name." + /> +
+ + +
+
+
+ controller.abort(), timeoutMs); + try { + const res = await undiciFetch(relayUrl, { + method: "GET", + headers: { + "x-relay-target": "https://httpbin.org", + "x-relay-path": "/get", + }, + signal: controller.signal, + }); + return { + ok: res.ok, + status: res.status, + statusText: res.statusText, + elapsedMs: Date.now() - startedAt, + }; + } catch (err) { + return { + ok: false, + status: 500, + error: err?.name === "AbortError" ? "Relay test timed out" : (err?.message || String(err)), + }; + } finally { + clearTimeout(timer); + } +} // POST /api/proxy-pools/[id]/test - Test proxy pool entry export async function POST(request, { params }) { @@ -12,7 +43,9 @@ export async function POST(request, { params }) { return NextResponse.json({ error: "Proxy pool not found" }, { status: 404 }); } - const result = await testProxyUrl({ proxyUrl: proxyPool.proxyUrl }); + const result = proxyPool.type === "vercel" + ? await testVercelRelay(proxyPool.proxyUrl) + : await testProxyUrl({ proxyUrl: proxyPool.proxyUrl }); const now = new Date().toISOString(); await updateProxyPool(id, { diff --git a/src/app/api/proxy-pools/route.js b/src/app/api/proxy-pools/route.js index c5b8d9f..998a0d5 100644 --- a/src/app/api/proxy-pools/route.js +++ b/src/app/api/proxy-pools/route.js @@ -7,12 +7,15 @@ function toBoolean(value) { return undefined; } +const VALID_PROXY_TYPES = ["http", "vercel"]; + function normalizeProxyPoolInput(body = {}) { const name = typeof body?.name === "string" ? body.name.trim() : ""; const proxyUrl = typeof body?.proxyUrl === "string" ? body.proxyUrl.trim() : ""; const noProxy = typeof body?.noProxy === "string" ? body.noProxy.trim() : ""; const isActive = body?.isActive === undefined ? true : body.isActive === true; const strictProxy = body?.strictProxy === true; + const type = VALID_PROXY_TYPES.includes(body?.type) ? body.type : "http"; if (!name) { return { error: "Name is required" }; @@ -22,7 +25,7 @@ function normalizeProxyPoolInput(body = {}) { return { error: "Proxy URL is required" }; } - return { name, proxyUrl, noProxy, isActive, strictProxy }; + return { name, proxyUrl, noProxy, isActive, strictProxy, type }; } function buildUsageMap(connections = []) { diff --git a/src/app/api/proxy-pools/vercel-deploy/route.js b/src/app/api/proxy-pools/vercel-deploy/route.js new file mode 100644 index 0000000..e87390f --- /dev/null +++ b/src/app/api/proxy-pools/vercel-deploy/route.js @@ -0,0 +1,142 @@ +import { NextResponse } from "next/server"; +import { createProxyPool } from "@/models"; + +const VERCEL_API = "https://api.vercel.com"; + +// Relay function source code deployed to Vercel +// Forwards requests to target URL specified in x-relay-target header +const RELAY_FUNCTION_CODE = ` +export const config = { runtime: "edge" }; + +export default async function handler(req) { + const target = req.headers.get("x-relay-target"); + const relayPath = req.headers.get("x-relay-path") || "/"; + if (!target) { + return new Response(JSON.stringify({ error: "Missing x-relay-target header" }), { + status: 400, + headers: { "content-type": "application/json" }, + }); + } + + const targetUrl = target.replace(/\\/$/, "") + relayPath; + + const headers = new Headers(req.headers); + headers.delete("x-relay-target"); + headers.delete("x-relay-path"); + headers.delete("host"); + + const response = await fetch(targetUrl, { + method: req.method, + headers, + body: req.method !== "GET" && req.method !== "HEAD" ? req.body : undefined, + duplex: "half", + }); + + return new Response(response.body, { + status: response.status, + headers: response.headers, + }); +} +`; + +async function pollDeployment(deploymentId, token, maxMs = 120000) { + const start = Date.now(); + while (Date.now() - start < maxMs) { + const res = await fetch(`${VERCEL_API}/v13/deployments/${deploymentId}`, { + headers: { Authorization: `Bearer ${token}` }, + }); + const data = await res.json(); + if (data.readyState === "READY") return data; + if (data.readyState === "ERROR" || data.readyState === "CANCELED") { + throw new Error(`Deployment failed: ${data.readyState}`); + } + await new Promise((r) => setTimeout(r, 3000)); + } + throw new Error("Deployment timed out"); +} + +// POST /api/proxy-pools/vercel-deploy +export async function POST(request) { + try { + const body = await request.json(); + const vercelToken = body.vercelToken; + const projectName = body.projectName?.trim() || `relay-${Date.now().toString(36)}`; + + if (!vercelToken) { + return NextResponse.json({ error: "Vercel API token is required" }, { status: 400 }); + } + + // Deploy relay function to Vercel + const deployRes = await fetch(`${VERCEL_API}/v13/deployments`, { + method: "POST", + headers: { + Authorization: `Bearer ${vercelToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + name: projectName, + files: [ + { + file: "api/relay.js", + data: RELAY_FUNCTION_CODE, + }, + { + file: "package.json", + data: JSON.stringify({ name: projectName, version: "1.0.0" }), + }, + { + file: "vercel.json", + data: JSON.stringify({ + rewrites: [{ source: "/(.*)", destination: "/api/relay" }], + }), + }, + ], + projectSettings: { + framework: null, + }, + target: "production", + }), + }); + + if (!deployRes.ok) { + const err = await deployRes.json().catch(() => ({})); + return NextResponse.json( + { error: err.error?.message || "Failed to create Vercel deployment" }, + { status: deployRes.status } + ); + } + + const deployment = await deployRes.json(); + const deploymentId = deployment.id || deployment.uid; + + // Disable deployment protection (Vercel Authentication) + const projectId = deployment.projectId || projectName; + await fetch(`${VERCEL_API}/v9/projects/${projectId}`, { + method: "PATCH", + headers: { + Authorization: `Bearer ${vercelToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ ssoProtection: null }), + }); + + // Poll until deployment is ready + const ready = await pollDeployment(deploymentId, vercelToken); + const deployUrl = `https://${ready.url}`; + + // Create proxy pool entry with type vercel + const proxyPool = await createProxyPool({ + name: projectName, + proxyUrl: deployUrl, + type: "vercel", + noProxy: "", + isActive: true, + strictProxy: false, + }); + + return NextResponse.json({ proxyPool, deployUrl }, { status: 201 }); + } catch (error) { + console.log("Error deploying Vercel relay:", error); + return NextResponse.json({ error: error.message || "Deploy failed" }, { status: 500 }); + } +} diff --git a/src/lib/localDb.js b/src/lib/localDb.js index 581afcf..0884808 100644 --- a/src/lib/localDb.js +++ b/src/lib/localDb.js @@ -496,6 +496,7 @@ export async function createProxyPool(data) { name: data.name, proxyUrl: data.proxyUrl, noProxy: data.noProxy || "", + type: data.type || "http", isActive: data.isActive !== undefined ? data.isActive : true, strictProxy: data.strictProxy === true, testStatus: data.testStatus || "unknown", diff --git a/src/lib/network/connectionProxy.js b/src/lib/network/connectionProxy.js index cb51f11..7243033 100644 --- a/src/lib/network/connectionProxy.js +++ b/src/lib/network/connectionProxy.js @@ -28,6 +28,20 @@ export async function resolveConnectionProxyConfig(providerSpecificData = {}) { const noProxy = normalizeString(proxyPool?.noProxy); if (proxyPool && proxyPool.isActive === true && proxyUrl) { + // Vercel relay: rewrite base URL instead of using HTTP_PROXY + if (proxyPool.type === "vercel") { + return { + source: "vercel", + proxyPoolId, + proxyPool, + connectionProxyEnabled: false, + connectionProxyUrl: "", + connectionNoProxy: noProxy, + strictProxy: proxyPool.strictProxy === true, + vercelRelayUrl: proxyUrl, + }; + } + return { source: "pool", proxyPoolId, diff --git a/src/sse/services/auth.js b/src/sse/services/auth.js index 3f426c1..9f13ffb 100644 --- a/src/sse/services/auth.js +++ b/src/sse/services/auth.js @@ -145,6 +145,7 @@ export async function getProviderCredentials(provider, excludeConnectionIds = nu connectionProxyUrl: resolvedProxy.connectionProxyUrl, connectionNoProxy: resolvedProxy.connectionNoProxy, connectionProxyPoolId: resolvedProxy.proxyPoolId || null, + vercelRelayUrl: resolvedProxy.vercelRelayUrl || "", }, connectionId: connection.id, // Include current status for optimization check