From 64dcf9c2ef23b3bd6a7cf8385a1bec0fe4bb6917 Mon Sep 17 00:00:00 2001 From: TechWIthTy <37724661+TechWithTy@users.noreply.github.com> Date: Tue, 7 Oct 2025 18:51:13 -0600 Subject: [PATCH] feat: add Vertex AI proxy routes --- .../vertix/[[...vertexPath]]/route.ts | 177 ++++++++++++++++++ .../vertix/__tests__/_utils.test.ts | 81 ++++++++ app/api/gemini-vertix/vertix/_utils.ts | 61 ++++++ 3 files changed, 319 insertions(+) create mode 100644 app/api/gemini-vertix/vertix/[[...vertexPath]]/route.ts create mode 100644 app/api/gemini-vertix/vertix/__tests__/_utils.test.ts create mode 100644 app/api/gemini-vertix/vertix/_utils.ts diff --git a/app/api/gemini-vertix/vertix/[[...vertexPath]]/route.ts b/app/api/gemini-vertix/vertix/[[...vertexPath]]/route.ts new file mode 100644 index 000000000..a59539d6c --- /dev/null +++ b/app/api/gemini-vertix/vertix/[[...vertexPath]]/route.ts @@ -0,0 +1,177 @@ +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; + +import { + DEFAULT_VERTEX_ENDPOINT, + buildVertexTargetUrl, + selectVertexEndpoint, + type VertexEndpointConfig, +} from "../_utils"; + +type RouteContext = { + params: { + vertexPath?: string[]; + }; +}; + +const FORBIDDEN_REQUEST_HEADERS = new Set([ + "connection", + "content-length", + "host", + "origin", + "referer", + "x-forwarded-for", + "x-forwarded-host", + "x-forwarded-port", + "x-forwarded-proto", + "x-forwarded-scheme", + "x-real-ip", + "x-vertex-region", + "x-vertex-endpoint", +]); + +const DEFAULT_ENDPOINT_CONFIG: VertexEndpointConfig = { + defaultEndpoint: process.env.VERTEX_API_BASE_URL ?? DEFAULT_VERTEX_ENDPOINT, + defaultRegion: + process.env.VERTEX_REGION ?? process.env.VERTEX_LOCATION ?? null, +}; + +function createVertexUrl(req: NextRequest, pathSegments: string[]): string { + const requestUrl = new URL(req.url); + const endpointOverride = req.headers.get("x-vertex-endpoint"); + const regionOverride = req.headers.get("x-vertex-region"); + + const endpoint = selectVertexEndpoint( + DEFAULT_ENDPOINT_CONFIG, + regionOverride, + endpointOverride, + ); + + return buildVertexTargetUrl(endpoint, pathSegments, requestUrl.search); +} + +function buildForwardHeaders(req: NextRequest): Headers { + const headers = new Headers(); + + req.headers.forEach((value, key) => { + if (FORBIDDEN_REQUEST_HEADERS.has(key.toLowerCase())) { + return; + } + + headers.set(key, value); + }); + + if (process.env.VERTEX_API_KEY && !headers.has("x-goog-api-key")) { + headers.set("x-goog-api-key", process.env.VERTEX_API_KEY); + } + + if (process.env.VERTEX_PROJECT_ID && !headers.has("x-goog-user-project")) { + headers.set("x-goog-user-project", process.env.VERTEX_PROJECT_ID); + } + + if (process.env.VERTEX_ACCESS_TOKEN && !headers.has("authorization")) { + headers.set("authorization", `Bearer ${process.env.VERTEX_ACCESS_TOKEN}`); + } + + if (!headers.has("accept")) { + headers.set("accept", "application/json"); + } + + return headers; +} + +async function proxyRequest( + req: NextRequest, + context: RouteContext, +): Promise { + const pathSegments = context.params.vertexPath?.filter(Boolean) ?? []; + + if (!pathSegments.length) { + return NextResponse.json( + { error: "Vertex API path is required." }, + { status: 400 }, + ); + } + + let targetUrl: string; + + try { + targetUrl = createVertexUrl(req, pathSegments); + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown error"; + return NextResponse.json({ error: message }, { status: 400 }); + } + + const headers = buildForwardHeaders(req); + + const init: RequestInit = { + method: req.method, + headers, + redirect: "manual", + }; + + if (req.body && !["GET", "HEAD"].includes(req.method.toUpperCase())) { + init.body = req.body; + // @ts-expect-error - duplex is required for streaming requests in Node. + init.duplex = "half"; + } + + try { + const vertexResponse = await fetch(targetUrl, init); + const responseHeaders = new Headers(); + + vertexResponse.headers.forEach((value, key) => { + if (key.toLowerCase() === "content-length") { + return; + } + + responseHeaders.set(key, value); + }); + + return new NextResponse(vertexResponse.body, { + status: vertexResponse.status, + statusText: vertexResponse.statusText, + headers: responseHeaders, + }); + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown error"; + + return NextResponse.json( + { + error: "Failed to contact Vertex AI.", + details: message, + }, + { status: 502 }, + ); + } +} + +export const dynamic = "force-dynamic"; + +export function GET(req: NextRequest, context: RouteContext) { + return proxyRequest(req, context); +} + +export function POST(req: NextRequest, context: RouteContext) { + return proxyRequest(req, context); +} + +export function PUT(req: NextRequest, context: RouteContext) { + return proxyRequest(req, context); +} + +export function PATCH(req: NextRequest, context: RouteContext) { + return proxyRequest(req, context); +} + +export function DELETE(req: NextRequest, context: RouteContext) { + return proxyRequest(req, context); +} + +export function HEAD(req: NextRequest, context: RouteContext) { + return proxyRequest(req, context); +} + +export function OPTIONS(req: NextRequest, context: RouteContext) { + return proxyRequest(req, context); +} diff --git a/app/api/gemini-vertix/vertix/__tests__/_utils.test.ts b/app/api/gemini-vertix/vertix/__tests__/_utils.test.ts new file mode 100644 index 000000000..b338ce225 --- /dev/null +++ b/app/api/gemini-vertix/vertix/__tests__/_utils.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from "vitest"; + +import { + DEFAULT_VERTEX_ENDPOINT, + buildVertexTargetUrl, + selectVertexEndpoint, + sanitizeEndpoint, + type VertexEndpointConfig, +} from "../_utils"; + +describe("selectVertexEndpoint", () => { + const baseConfig: VertexEndpointConfig = { + defaultEndpoint: DEFAULT_VERTEX_ENDPOINT, + defaultRegion: "us-central1", + }; + + it("prefers an explicit endpoint override", () => { + const endpoint = selectVertexEndpoint( + baseConfig, + null, + "https://example.com/", + ); + expect(endpoint).toBe("https://example.com"); + }); + + it("builds a regional endpoint when a region override is provided", () => { + const endpoint = selectVertexEndpoint(baseConfig, "europe-west4", null); + expect(endpoint).toBe("https://europe-west4-aiplatform.googleapis.com"); + }); + + it("falls back to the configured region when no override is provided", () => { + const endpoint = selectVertexEndpoint(baseConfig, undefined, null); + expect(endpoint).toBe("https://us-central1-aiplatform.googleapis.com"); + }); + + it("falls back to the default endpoint when no region is available", () => { + const endpoint = selectVertexEndpoint( + { defaultEndpoint: DEFAULT_VERTEX_ENDPOINT }, + null, + null, + ); + expect(endpoint).toBe(DEFAULT_VERTEX_ENDPOINT); + }); +}); + +describe("buildVertexTargetUrl", () => { + it("joins path segments and preserves the search string", () => { + const url = buildVertexTargetUrl( + "https://us-central1-aiplatform.googleapis.com/", + ["v1", "projects", "demo"], + "?foo=bar", + ); + + expect(url).toBe( + "https://us-central1-aiplatform.googleapis.com/v1/projects/demo?foo=bar", + ); + }); + + it("throws when no path segments are provided", () => { + expect(() => buildVertexTargetUrl(DEFAULT_VERTEX_ENDPOINT, [], "")).toThrow( + /path is required/i, + ); + }); + + it("normalizes whitespace in the base endpoint", () => { + const url = buildVertexTargetUrl( + " https://api.example.com/ ", + ["v1", "items"], + "", + ); + expect(url).toBe("https://api.example.com/v1/items"); + }); +}); + +describe("sanitizeEndpoint", () => { + it("trims whitespace and trailing slashes", () => { + expect(sanitizeEndpoint(" https://api.example.com/// ")).toBe( + "https://api.example.com", + ); + }); +}); diff --git a/app/api/gemini-vertix/vertix/_utils.ts b/app/api/gemini-vertix/vertix/_utils.ts new file mode 100644 index 000000000..c7d2aef11 --- /dev/null +++ b/app/api/gemini-vertix/vertix/_utils.ts @@ -0,0 +1,61 @@ +export const DEFAULT_VERTEX_ENDPOINT = "https://aiplatform.googleapis.com"; + +export type VertexEndpointConfig = { + defaultEndpoint?: string | null; + defaultRegion?: string | null; +}; + +const REGION_PATTERN = /^[a-z0-9-]+$/i; + +export function sanitizeEndpoint(endpoint: string): string { + const trimmed = endpoint.trim(); + return trimmed.replace(/\/+$/, ""); +} + +export function selectVertexEndpoint( + config: VertexEndpointConfig, + regionOverride?: string | null, + explicitEndpoint?: string | null, +): string { + if (explicitEndpoint && explicitEndpoint.trim().length > 0) { + return sanitizeEndpoint(explicitEndpoint); + } + + const normalizedRegion = + regionOverride?.trim().toLowerCase() || + config.defaultRegion?.trim().toLowerCase(); + + if (normalizedRegion && REGION_PATTERN.test(normalizedRegion)) { + return `https://${normalizedRegion}-aiplatform.googleapis.com`; + } + + const fallback = config.defaultEndpoint?.trim(); + + if (fallback) { + return sanitizeEndpoint(fallback); + } + + return DEFAULT_VERTEX_ENDPOINT; +} + +export function buildVertexTargetUrl( + baseEndpoint: string, + pathSegments: string[], + search: string, +): string { + if (!pathSegments.length) { + throw new Error("Vertex API path is required"); + } + + const cleanedSegments = pathSegments.filter(Boolean); + + if (!cleanedSegments.length) { + throw new Error("Vertex API path is required"); + } + + const base = sanitizeEndpoint(baseEndpoint); + const normalizedSearch = + search.startsWith("?") || search.length === 0 ? search : `?${search}`; + + return `${base}/${cleanedSegments.join("/")}${normalizedSearch}`; +}