Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
177 changes: 177 additions & 0 deletions app/api/gemini-vertix/vertix/[[...vertexPath]]/route.ts
Original file line number Diff line number Diff line change
@@ -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<Response> {
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);
}
81 changes: 81 additions & 0 deletions app/api/gemini-vertix/vertix/__tests__/_utils.test.ts
Original file line number Diff line number Diff line change
@@ -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",
);
});
});
61 changes: 61 additions & 0 deletions app/api/gemini-vertix/vertix/_utils.ts
Original file line number Diff line number Diff line change
@@ -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}`;
}
Loading