-
-
Notifications
You must be signed in to change notification settings - Fork 387
feat: proxy MiniMax H3 video generation #1427
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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"), | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
If a provider uses the supported base URL Knowledge Base Used: Proxy request pipeline Prompt To Fix With AIThis is a comment left during a code review.
Path: src/app/v1/_lib/proxy/endpoint-family-catalog.ts
Line: 151
Comment:
**Query path duplicates base segments**
If a provider uses the supported base URL `https://api.minimax.io/v2/video_generation`, a task query falls through to standard URL concatenation and produces `/v2/video_generation/v2/query/video_generation/{task}`, causing the upstream request to fail on a nonexistent path.
**Knowledge Base Used:** [Proxy request pipeline](https://app.greptile.com/ygxz/-/custom-context/knowledge-base/ding113/claude-code-hub/-/docs/proxy-pipeline.md)
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly. |
||
| }, | ||
| { | ||
| 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", | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<string, unknown>; | ||
| }): 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}.`); | ||
|
Comment on lines
+53
to
+54
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
These new validation failures are returned to clients through Useful? React with 👍 / 👎. |
||
| } | ||
|
|
||
| if (!Array.isArray(body.content) || body.content.length !== 1) { | ||
| return fail("Invalid request: text-to-video content must contain exactly one text item."); | ||
|
Comment on lines
+57
to
+58
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
For Useful? React with 👍 / 👎. |
||
| } | ||
|
|
||
| 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<string, unknown>; | ||
| 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."); | ||
|
Comment on lines
+53
to
+89
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift 🧩 Analysis chain🏁 Script executed: #!/bin/bash
set -e
printf '%s\n' '--- target implementation ---'
sed -n '1,180p' src/app/v1/_lib/proxy/video-generation-v2.ts
printf '%s\n' '--- related tests ---'
sed -n '1,180p' tests/unit/proxy/video-generation-v2.test.ts
printf '%s\n' '--- error and localization references ---'
rg -n --glob '*.{ts,tsx,json}' 'ProxyError|next-intl|Invalid request|error code|errorCode|validate\(' src tests | head -240
printf '%s\n' '--- v1 proxy structure ---'
fd -t f . src/app/v1 | sort | head -160Repository: ding113/claude-code-hub Length of output: 35867 🏁 Script executed: #!/bin/bash
set -e
printf '%s\n' '--- validator call sites ---'
rg -n -C 8 'validateVideoGenerationV2TextRequest|VIDEO_GENERATION_V2_CREATE_PATH|video_generation' src/app/v1 tests | head -320
printf '%s\n' '--- proxy error definitions and handling ---'
sed -n '1,260p' src/app/v1/_lib/proxy/errors.ts
sed -n '1,280p' src/app/v1/_lib/proxy/error-handler.ts
sed -n '150,230p' src/app/v1/_lib/proxy-handler.ts
printf '%s\n' '--- route and response construction ---'
sed -n '1,260p' 'src/app/v1/[...route]/route.ts'
printf '%s\n' '--- API error localization flow ---'
rg -n -C 10 'api-error-i18n|errorCode.*detail|errorParams|request.validation_failed|messages.*error' src tests | head -320Repository: ding113/claude-code-hub Length of output: 50379 🏁 Script executed: #!/bin/bash
set -e
printf '%s\n' '--- validation throw and response path ---'
sed -n '3308,3355p' src/app/v1/_lib/proxy/forwarder.ts
rg -n -C 6 'buildError|clientErrorMessage|resolveFinalClientErrorMessage|getLocale' src/app/v1/_lib/proxy/error-handler.ts src/app/v1/_lib/proxy/responses.ts src/app/v1/_lib/proxy-handler.ts
printf '%s\n' '--- proxy response implementation ---'
rg -n 'class ProxyResponses|buildError|errorCode|detail|message' src/app/v1/_lib/proxy/responses.ts src/app/v1/_lib/proxy/errors.ts | head -180
sed -n '1,180p' src/app/v1/_lib/proxy/responses.ts
printf '%s\n' '--- locale use in v1 code ---'
rg -n -C 4 'getLocale|Accept-Language|accept-language|locale' src/app/v1 src/i18n | head -220
printf '%s\n' '--- translation message structure ---'
fd -t f . messages src | rg '(^|/)(en|zh-CN|zh-TW|ja|ru)(/|\\.|$)|messages|error-messages'
sed -n '1,240p' src/lib/utils/error-messages.tsRepository: ding113/claude-code-hub Length of output: 50381 🏁 Script executed: #!/bin/bash
set -e
printf '%s\n' '--- proxy API error contract tests ---'
rg -n -C 8 'invalid_request|invalid_request_error|error\.message|ProxyResponses\.buildError|ProxyErrorHandler' tests/api tests/integration tests/unit/proxy | head -260
printf '%s\n' '--- existing proxy validation error patterns ---'
rg -n -C 3 'new ProxyError\(|return fail\(|Invalid request:' src/app/v1/_lib/proxy src/app/v1/_lib | head -280
printf '%s\n' '--- frontend API error translation boundary ---'
rg -n -C 8 'function getApiErrorMessageKey|class ApiError|getApiErrorMessageKey|errorCode' src/lib src/hooks src/components | head -220
printf '%s\n' '--- read-only call-graph invariant check ---'
python3 - <<'PY'
from pathlib import Path
validator = Path("src/app/v1/_lib/proxy/video-generation-v2.ts").read_text()
forwarder = Path("src/app/v1/_lib/proxy/forwarder.ts").read_text()
responses = Path("src/app/v1/_lib/proxy/responses.ts").read_text()
checks = {
"validator returns message field": "ok: false; message: string" in validator,
"forwarder throws ProxyError from validation message":
"throw new ProxyError(videoValidation.message, 400);" in forwarder,
"ProxyResponses serializes message":
'"message": string' in responses and "message," in responses,
"ProxyResponses derives code from status/type":
"getErrorCode(status, finalType)" in responses,
"ProxyResponses has no validation error-code argument":
"buildError(\n status: number,\n message: string,\n errorType?: string" in responses,
}
for name, result in checks.items():
print(f"{name}: {result}")
PYRepository: ding113/claude-code-hub Length of output: 50379 🏁 Script executed: #!/bin/bash
set -e
printf '%s\n' '--- existing localized proxy error precedent ---'
sed -n '1180,1220p' src/app/v1/_lib/proxy/session.ts
rg -n -C 5 'export (const|type).*ERROR_CODES|function getErrorMessageServer|getErrorMessageServer' src/lib/utils/error-messages.ts
printf '%s\n' '--- exact error response schema and tests ---'
python3 - <<'PY'
from pathlib import Path
forwarder = Path("src/app/v1/_lib/proxy/forwarder.ts").read_text()
handler = Path("src/app/v1/_lib/proxy/error-handler.ts").read_text()
responses = Path("src/app/v1/_lib/proxy/responses.ts").read_text()
validator = Path("src/app/v1/_lib/proxy/video-generation-v2.ts").read_text()
assert "throw new ProxyError(videoValidation.message, 400);" in forwarder
assert "clientErrorMessage = error.getClientSafeMessage();" in handler
assert "ProxyResponses.buildError(" in handler
assert "message," in responses
assert "getErrorCode(status, finalType)" in responses
assert "{ ok: false; message: string }" in validator
print("video validation -> ProxyError(message) -> client error message -> error.message: PASS")
print("response code is derived from HTTP status/type, not a validation-specific code: PASS")
print("validator currently exposes presentation text rather than a machine-readable validation code: PASS")
PYRepository: ding113/claude-code-hub Length of output: 2932 为视频生成验证错误使用 i18n 和稳定错误码。 验证错误会通过
📍 Affects 2 files
🤖 Prompt for AI AgentsSource: Coding guidelines |
||
| } | ||
|
|
||
| return { ok: true, request: body as unknown as VideoGenerationV2TextRequest }; | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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, | ||
| }; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<string, unknown>) { | ||
| 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 })); | ||
| }); | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Adding this
/v2/video_generationfamily without updatingbuildProxyUrl's endpoint list means providers configured with the version-root base URL pattern that the helper supports elsewhere, e.g.https://api.minimax.io/v2, are called ashttps://api.minimax.io/v2/v2/video_generationbecause/video_generationdoes not match any endpoint regex and falls through to plain concatenation. Add the new video paths totargetEndpointsso version-root and endpoint-root provider URLs route correctly.Useful? React with 👍 / 👎.