diff --git a/src/app/v1/_lib/proxy/endpoint-family-catalog.ts b/src/app/v1/_lib/proxy/endpoint-family-catalog.ts index 8659dc1bc..a766b44af 100644 --- a/src/app/v1/_lib/proxy/endpoint-family-catalog.ts +++ b/src/app/v1/_lib/proxy/endpoint-family-catalog.ts @@ -134,6 +134,30 @@ const KNOWN_ENDPOINT_FAMILIES: readonly EndpointFamily[] = Object.freeze([ rawPassthrough: false, match: (pathname) => hasPrefix(pathname, "/v1/chat/completions"), }, + { + id: "video-generation-v2-create", + surface: "openai", + accountingTier: "none", + modelRequired: true, + rawPassthrough: false, + match: (pathname) => pathname === "/v2/video_generation", + }, + { + id: "video-generation-v2-query", + surface: "openai", + accountingTier: "none", + modelRequired: false, + rawPassthrough: false, + match: (pathname) => hasPrefix(pathname, "/v2/query/video_generation"), + }, + { + id: "video-generation-v2-resources", + surface: "openai", + accountingTier: "none", + modelRequired: false, + rawPassthrough: false, + match: (pathname) => hasPrefix(pathname, "/v2/video_generation"), + }, { id: "openai-models", surface: "openai", diff --git a/src/app/v1/_lib/proxy/forwarder.ts b/src/app/v1/_lib/proxy/forwarder.ts index 84bc26942..e3b4beaae 100644 --- a/src/app/v1/_lib/proxy/forwarder.ts +++ b/src/app/v1/_lib/proxy/forwarder.ts @@ -141,6 +141,7 @@ import { type ThinkingSignatureRectifierResult, type ThinkingSignatureRectifierTrigger, } from "./thinking-signature-rectifier"; +import { validateVideoGenerationV2TextRequest } from "./video-generation-v2"; /** Default User-Agent for Codex CLI requests when none is provided */ export const DEFAULT_CODEX_USER_AGENT = @@ -3332,6 +3333,15 @@ export class ProxyForwarder { throw new ProxyError(validation.message ?? "Invalid request.", 400); } + const videoValidation = validateVideoGenerationV2TextRequest({ + pathname: requestPath, + method: session.method, + body: messageToSend, + }); + if (!videoValidation.ok) { + throw new ProxyError(videoValidation.message, 400); + } + const bodyString = JSON.stringify(messageToSend); requestBody = bodyString; session.forwardedRequestBody = bodyString; diff --git a/src/app/v1/_lib/proxy/video-generation-v2.ts b/src/app/v1/_lib/proxy/video-generation-v2.ts new file mode 100644 index 000000000..d10e5c36f --- /dev/null +++ b/src/app/v1/_lib/proxy/video-generation-v2.ts @@ -0,0 +1,93 @@ +import { normalizeEndpointPath } from "./endpoint-paths"; + +export const VIDEO_GENERATION_V2_CREATE_PATH = "/v2/video_generation"; + +const TEXT_TO_VIDEO_MODEL = "MiniMax-H3"; +const TEXT_TO_VIDEO_RESOLUTION = "2K"; +const TEXT_TO_VIDEO_RATIOS = new Set(["21:9", "16:9", "4:3", "1:1", "3:4", "9:16"]); + +export interface VideoGenerationV2TextContent { + type: "text"; + text: string; +} + +export interface VideoGenerationV2TextRequest { + model: typeof TEXT_TO_VIDEO_MODEL; + content: [VideoGenerationV2TextContent]; + resolution: typeof TEXT_TO_VIDEO_RESOLUTION; + duration: number; + ratio: string; + callback_url?: string; +} + +export type VideoGenerationV2ValidationResult = + | { ok: true; request: VideoGenerationV2TextRequest } + | { ok: false; message: string }; + +function fail(message: string): VideoGenerationV2ValidationResult { + return { ok: false, message }; +} + +function isHttpUrl(value: string): boolean { + try { + const url = new URL(value); + return url.protocol === "http:" || url.protocol === "https:"; + } catch { + return false; + } +} + +export function validateVideoGenerationV2TextRequest(params: { + pathname: string; + method: string; + body: Record; +}): VideoGenerationV2ValidationResult { + if ( + params.method.toUpperCase() !== "POST" || + normalizeEndpointPath(params.pathname) !== VIDEO_GENERATION_V2_CREATE_PATH + ) { + return { ok: true, request: params.body as unknown as VideoGenerationV2TextRequest }; + } + + const { body } = params; + if (body.model !== TEXT_TO_VIDEO_MODEL) { + return fail(`Invalid request: model must be ${TEXT_TO_VIDEO_MODEL}.`); + } + + if (!Array.isArray(body.content) || body.content.length !== 1) { + return fail("Invalid request: text-to-video content must contain exactly one text item."); + } + + const content = body.content[0]; + if (typeof content !== "object" || content === null || Array.isArray(content)) { + return fail("Invalid request: content must be an object."); + } + const textContent = content as Record; + if (textContent.type !== "text") { + return fail("Invalid request: text-to-video content type must be text."); + } + if (typeof textContent.text !== "string" || textContent.text.trim().length === 0) { + return fail("Invalid request: text-to-video requires a non-empty text prompt."); + } + if (textContent.text.length > 7000) { + return fail("Invalid request: text prompt must not exceed 7000 characters."); + } + + if (body.resolution !== TEXT_TO_VIDEO_RESOLUTION) { + return fail(`Invalid request: resolution must be ${TEXT_TO_VIDEO_RESOLUTION}.`); + } + if (!Number.isInteger(body.duration) || Number(body.duration) < 4 || Number(body.duration) > 15) { + return fail("Invalid request: duration must be an integer from 4 to 15 seconds."); + } + if (typeof body.ratio !== "string" || !TEXT_TO_VIDEO_RATIOS.has(body.ratio)) { + return fail("Invalid request: text-to-video requires a supported non-adaptive ratio."); + } + if ( + body.callback_url !== undefined && + (typeof body.callback_url !== "string" || !isHttpUrl(body.callback_url)) + ) { + return fail("Invalid request: callback_url must be an HTTP or HTTPS URL."); + } + + return { ok: true, request: body as unknown as VideoGenerationV2TextRequest }; +} diff --git a/src/app/v2/[...route]/route.ts b/src/app/v2/[...route]/route.ts new file mode 100644 index 000000000..64d4f64d1 --- /dev/null +++ b/src/app/v2/[...route]/route.ts @@ -0,0 +1,23 @@ +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"; +import { withDataDbScope } from "@/drizzle/db"; + +export const runtime = "nodejs"; + +const app = new Hono().basePath("/v2"); + +registerCors(app); +app.all("*", handleProxyRequest); + +const routeHandler = withDataDbScope(handle(app)); + +export { + routeHandler as GET, + routeHandler as POST, + routeHandler as DELETE, + routeHandler as OPTIONS, + routeHandler as HEAD, +}; diff --git a/src/proxy.matcher.ts b/src/proxy.matcher.ts index 61fe2adb8..a02433f03 100644 --- a/src/proxy.matcher.ts +++ b/src/proxy.matcher.ts @@ -2,7 +2,7 @@ // // Match all request paths except for the ones starting with: // - api (API routes - own auth via cookie session, no proxy needed) -// - v1 / v1beta (API proxy routes - own auth via Bearer token; matching +// - v1 / v1beta / v2 (API proxy routes - own auth via Bearer token; matching // them here also forces Next.js to clone the request body via // getCloneableBody → cloneBodyStream, which clamps proxied bodies to // experimental.proxyClientMaxBodySize for no benefit since we no-op @@ -18,4 +18,4 @@ // literals so its build-time static analyzer can collect them. The unit // test in `tests/unit/proxy-matcher.test.ts` enforces drift between the two. export const proxyMatcherPattern = - "/((?!api|v1(?:/|$)|v1beta(?:/|$)|_next/static|_next/image|favicon.ico).*)"; + "/((?!api|v1(?:/|$)|v1beta(?:/|$)|v2(?:/|$)|_next/static|_next/image|favicon.ico).*)"; diff --git a/src/proxy.ts b/src/proxy.ts index 946cf83e4..b941db6c4 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -126,5 +126,5 @@ export const config = { // so Next.js's build-time static analyzer can collect it. The unit test // `tests/unit/proxy-matcher.test.ts` asserts the two stay in sync. See the // matcher module for the full per-segment rationale. - matcher: ["/((?!api|v1(?:/|$)|v1beta(?:/|$)|_next/static|_next/image|favicon.ico).*)"], + matcher: ["/((?!api|v1(?:/|$)|v1beta(?:/|$)|v2(?:/|$)|_next/static|_next/image|favicon.ico).*)"], }; diff --git a/tests/unit/app/v1/url.test.ts b/tests/unit/app/v1/url.test.ts index 0c06fbd79..07c42ca55 100644 --- a/tests/unit/app/v1/url.test.ts +++ b/tests/unit/app/v1/url.test.ts @@ -88,6 +88,19 @@ describe("buildProxyUrl", () => { ); }); + test("preserves video generation v2 regional endpoint paths", () => { + expectBuiltUrl( + "https://api.minimax.io/v2/video_generation", + "/v2/video_generation", + "https://api.minimax.io/v2/video_generation" + ); + expectBuiltUrl( + "https://api.minimaxi.com", + "/v2/query/video_generation/task_123", + "https://api.minimaxi.com/v2/query/video_generation/task_123" + ); + }); + test("完整 Codex path:baseUrl 已包含 /openai/v1/responses 时保持原路径", () => { expectBuiltUrl( "https://relay.example.com/openai/v1/responses", diff --git a/tests/unit/proxy-matcher.test.ts b/tests/unit/proxy-matcher.test.ts index 42de1633e..167ddd2bc 100644 --- a/tests/unit/proxy-matcher.test.ts +++ b/tests/unit/proxy-matcher.test.ts @@ -18,6 +18,9 @@ describe("proxy matcher", () => { "/v1beta/messages", "/v1beta", "/v1beta/v1/foo", + "/v2/video_generation", + "/v2/query/video_generation/task_123", + "/v2", ])("does not match %s", (pathname) => { expect(matcher.test(pathname)).toBe(false); }); diff --git a/tests/unit/proxy/endpoint-family-catalog.test.ts b/tests/unit/proxy/endpoint-family-catalog.test.ts index efd8ea69c..5d7cbcc46 100644 --- a/tests/unit/proxy/endpoint-family-catalog.test.ts +++ b/tests/unit/proxy/endpoint-family-catalog.test.ts @@ -56,6 +56,27 @@ const FAMILY_SAMPLES = [ accountingTier: "none", modelRequired: false, }, + { + id: "video-generation-v2-create", + path: "/v2/video_generation", + format: "openai", + accountingTier: "none", + modelRequired: true, + }, + { + id: "video-generation-v2-query", + path: "/v2/query/video_generation/task_123", + format: "openai", + accountingTier: "none", + modelRequired: false, + }, + { + id: "video-generation-v2-resources", + path: "/v2/video_generation/task_123", + format: "openai", + accountingTier: "none", + modelRequired: false, + }, { id: "openai-completions", path: "/v1/completions", diff --git a/tests/unit/proxy/endpoint-family-provider-routing.test.ts b/tests/unit/proxy/endpoint-family-provider-routing.test.ts index 4722353fd..a1b39920d 100644 --- a/tests/unit/proxy/endpoint-family-provider-routing.test.ts +++ b/tests/unit/proxy/endpoint-family-provider-routing.test.ts @@ -53,6 +53,24 @@ const ENDPOINT_PROVIDER_CASES = [ requestedModel: "", expectedProviderType: "openai-compatible", }, + { + id: "video-generation-v2-create", + path: "/v2/video_generation", + requestedModel: "MiniMax-H3", + expectedProviderType: "openai-compatible", + }, + { + id: "video-generation-v2-query", + path: "/v2/query/video_generation/task_123", + requestedModel: "", + expectedProviderType: "openai-compatible", + }, + { + id: "video-generation-v2-resources", + path: "/v2/video_generation/task_123", + requestedModel: "", + expectedProviderType: "openai-compatible", + }, { id: "openai-completions", path: "/v1/completions", diff --git a/tests/unit/proxy/video-generation-v2.test.ts b/tests/unit/proxy/video-generation-v2.test.ts new file mode 100644 index 000000000..617d7b68d --- /dev/null +++ b/tests/unit/proxy/video-generation-v2.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, test } from "vitest"; +import { validateVideoGenerationV2TextRequest } from "@/app/v1/_lib/proxy/video-generation-v2"; + +const validRequest = { + model: "MiniMax-H3", + content: [{ type: "text", text: "A lighthouse above a stormy sea" }], + resolution: "2K", + duration: 5, + ratio: "16:9", +}; + +function validate(body: Record) { + return validateVideoGenerationV2TextRequest({ + pathname: "/v2/video_generation", + method: "POST", + body, + }); +} + +describe("video generation v2 text request", () => { + test("accepts the supported text-to-video schema", () => { + expect(validate(validRequest)).toEqual(expect.objectContaining({ ok: true })); + }); + + test.each([ + [{ ...validRequest, model: "unsupported" }, "model"], + [{ ...validRequest, content: [] }, "exactly one text item"], + [ + { ...validRequest, content: [{ type: "image_url", image_url: { url: "https://x" } }] }, + "content type", + ], + [{ ...validRequest, content: [{ type: "text", text: "" }] }, "non-empty text prompt"], + [{ ...validRequest, resolution: "768P" }, "resolution"], + [{ ...validRequest, duration: 3 }, "duration"], + [{ ...validRequest, ratio: "adaptive" }, "non-adaptive ratio"], + [{ ...validRequest, callback_url: "file:///tmp/result" }, "callback_url"], + ])("rejects invalid schema input %#", (body, message) => { + expect(validate(body)).toEqual({ ok: false, message: expect.stringContaining(message) }); + }); + + test("does not validate non-create requests", () => { + expect( + validateVideoGenerationV2TextRequest({ + pathname: "/v2/query/video_generation/task_123", + method: "GET", + body: {}, + }) + ).toEqual(expect.objectContaining({ ok: true })); + }); +});