Skip to content
Open
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
24 changes: 24 additions & 0 deletions src/app/v1/_lib/proxy/endpoint-family-catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Comment on lines +138 to +143

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Teach URL building about video_generation

Adding this /v2/video_generation family without updating buildProxyUrl'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 as https://api.minimax.io/v2/v2/video_generation because /video_generation does not match any endpoint regex and falls through to plain concatenation. Add the new video paths to targetEndpoints so version-root and endpoint-root provider URLs route correctly.

Useful? React with 👍 / 👎.

},
{
id: "video-generation-v2-query",
surface: "openai",
accountingTier: "none",
modelRequired: false,
rawPassthrough: false,
match: (pathname) => hasPrefix(pathname, "/v2/query/video_generation"),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 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

Prompt To Fix With AI
This 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",
Expand Down
10 changes: 10 additions & 0 deletions src/app/v1/_lib/proxy/forwarder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down Expand Up @@ -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;
Expand Down
93 changes: 93 additions & 0 deletions src/app/v1/_lib/proxy/video-generation-v2.ts
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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Localize video validation failures

These new validation failures are returned to clients through ProxyError, but the messages are hardcoded English literals. The repository requires user-facing strings to use i18n across the supported languages, so invalid /v2/video_generation requests bypass the localized error catalog; route these messages through the existing i18n/error helper instead.

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Check H3 content text before forwarding

For /v2/video_generation the user prompt is accepted in top-level content[].text, but the sensitive-word guard runs earlier in CHAT_PIPELINE and extractTextFromMessages only reads prompt, system, messages, and input. Any blocked term inside an H3 prompt therefore produces texts.length === 0 and is forwarded upstream; add extraction/normalization for this content shape before the guard decision.

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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 -160

Repository: 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 -320

Repository: 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.ts

Repository: 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}")
PY

Repository: 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")
PY

Repository: ding113/claude-code-hub

Length of output: 2932


为视频生成验证错误使用 i18n 和稳定错误码。

验证错误会通过 ProxyError 写入 API 的 error.message。当前消息为硬编码英文,且 error.code 只能表示 HTTP 状态,不能标识具体验证失败。

  • src/app/v1/_lib/proxy/video-generation-v2.ts:53-89:返回稳定错误码,并在响应边界根据请求语言生成消息。
  • tests/unit/proxy/video-generation-v2.test.ts:25-39:断言错误码,不要断言英文消息片段。
📍 Affects 2 files
  • src/app/v1/_lib/proxy/video-generation-v2.ts#L53-L89 (this comment)
  • tests/unit/proxy/video-generation-v2.test.ts#L25-L39
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/app/v1/_lib/proxy/video-generation-v2.ts` around lines 53 - 89, Update
the validation returns in video-generation-v2.ts around the request checks to
use stable validation error codes instead of hardcoded English messages, while
generating localized messages at the response boundary based on the request
language. Update tests/unit/proxy/video-generation-v2.test.ts lines 25-39 to
assert the relevant error codes rather than English message fragments.

Source: Coding guidelines

}

return { ok: true, request: body as unknown as VideoGenerationV2TextRequest };
}
23 changes: 23 additions & 0 deletions src/app/v2/[...route]/route.ts
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,
};
4 changes: 2 additions & 2 deletions src/proxy.matcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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).*)";
2 changes: 1 addition & 1 deletion src/proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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).*)"],
};
13 changes: 13 additions & 0 deletions tests/unit/app/v1/url.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 3 additions & 0 deletions tests/unit/proxy-matcher.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
Expand Down
21 changes: 21 additions & 0 deletions tests/unit/proxy/endpoint-family-catalog.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
18 changes: 18 additions & 0 deletions tests/unit/proxy/endpoint-family-provider-routing.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
50 changes: 50 additions & 0 deletions tests/unit/proxy/video-generation-v2.test.ts
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 }));
});
});
Loading