diff --git a/src/app/v1/[...route]/route.ts b/src/app/v1/[...route]/route.ts index 0876fb4e0..e2fe33141 100644 --- a/src/app/v1/[...route]/route.ts +++ b/src/app/v1/[...route]/route.ts @@ -2,6 +2,7 @@ import "@/lib/polyfills/file"; import { Hono } from "hono"; import { handle } from "hono/vercel"; import { handleChatCompletions } from "@/app/v1/_lib/codex/chat-completions-handler"; +import { registerCors } from "@/app/v1/_lib/cors"; import { handleProxyRequest } from "@/app/v1/_lib/proxy-handler"; import { logger } from "@/lib/logger"; import { sensitiveWordDetector } from "@/lib/sensitive-word-detector"; @@ -21,6 +22,8 @@ sensitiveWordDetector.reload().catch((err) => { const app = new Hono().basePath("/v1"); +registerCors(app); + // OpenAI Compatible API 路由 app.post("/chat/completions", handleChatCompletions); diff --git a/src/app/v1/_lib/cors.ts b/src/app/v1/_lib/cors.ts new file mode 100644 index 000000000..5bd05c944 --- /dev/null +++ b/src/app/v1/_lib/cors.ts @@ -0,0 +1,94 @@ +import type { Context, Hono } from "hono"; + +const DEFAULT_ALLOW_HEADERS = + "authorization,x-api-key,x-goog-api-key,content-type,anthropic-version,x-session-id,x-client-version"; + +const DEFAULT_CORS_HEADERS: Record = { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "GET,POST,PUT,PATCH,DELETE,OPTIONS", + "Access-Control-Allow-Headers": DEFAULT_ALLOW_HEADERS, + "Access-Control-Expose-Headers": + "x-request-id,x-ratelimit-limit,x-ratelimit-remaining,x-ratelimit-reset,retry-after", + "Access-Control-Max-Age": "86400", // 24 hours +}; + +/** + * 动态构建 CORS 响应头 + */ +function buildCorsHeaders(options: { origin?: string | null; requestHeaders?: string | null }) { + const headers = new Headers(DEFAULT_CORS_HEADERS); + + if (options.origin) { + headers.set("Access-Control-Allow-Origin", options.origin); + headers.append("Vary", "Origin"); + } + + if (options.requestHeaders) { + headers.set("Access-Control-Allow-Headers", options.requestHeaders); + headers.append("Vary", "Access-Control-Request-Headers"); + } + + if (headers.get("Access-Control-Allow-Origin") !== "*") { + headers.set("Access-Control-Allow-Credentials", "true"); + } + + return headers; +} + +/** + * 为响应添加 CORS 头 + */ +export function applyCors( + res: Response, + ctx: { origin?: string | null; requestHeaders?: string | null } +): Response { + const corsHeaders = buildCorsHeaders(ctx); + const headers = res.headers; + + if (!headers || typeof headers.set !== "function") { + const safeStatus = + typeof res.status === "number" && res.status >= 200 && res.status <= 599 ? res.status : 200; + return new Response(res.body, { status: safeStatus, headers: corsHeaders }); + } + + corsHeaders.forEach((value, key) => { + if (key === "vary") { + headers.append(key, value); + } else { + headers.set(key, value); + } + }); + return res; +} + +/** + * 构建预检请求响应 + */ +export function buildPreflightResponse(options: { + origin?: string | null; + requestHeaders?: string | null; +}): Response { + return new Response(null, { status: 204, headers: buildCorsHeaders(options) }); +} + +export const CORS_HEADERS = DEFAULT_CORS_HEADERS; + +/** + * 注册 CORS 中间件 + */ +export function registerCors(app: Hono): void { + app.use("*", async (c, next) => { + await next(); + return applyCors(c.res, { + origin: c.req.header("origin"), + requestHeaders: c.req.header("access-control-request-headers"), + }); + }); + + app.options("*", (c: Context) => + buildPreflightResponse({ + origin: c.req.header("origin"), + requestHeaders: c.req.header("access-control-request-headers"), + }) + ); +} diff --git a/src/app/v1beta/[...route]/route.ts b/src/app/v1beta/[...route]/route.ts index afbe5fddf..b59bff0ee 100644 --- a/src/app/v1beta/[...route]/route.ts +++ b/src/app/v1beta/[...route]/route.ts @@ -1,6 +1,7 @@ import "@/lib/polyfills/file"; import { Hono } from "hono"; import { handle } from "hono/vercel"; +import { registerCors } from "@/app/v1/_lib/cors"; import { handleProxyRequest } from "@/app/v1/_lib/proxy-handler"; export const runtime = "nodejs"; @@ -8,6 +9,8 @@ export const runtime = "nodejs"; // Gemini API 路由处理器(/v1beta/models/{model}:generateContent) const app = new Hono().basePath("/v1beta"); +registerCors(app); + // 所有 Gemini API 请求都通过 proxy handler 处理 // 格式检测会自动识别 Gemini 请求体中的 contents 字段 app.all("*", handleProxyRequest);