fix(models): 模型目录 - 保留人工能力并按声明发送视频分辨率

This commit is contained in:
ddCat
2026-08-17 12:56:51 +08:00
parent 050a95c1d5
commit c2352fd9b6
11 changed files with 218 additions and 48 deletions

View File

@@ -67,6 +67,7 @@ type VideoCapabilityConfig struct {
type VideoReferenceConfig struct {
PromptMaxChars int `json:"promptMaxChars"`
MinImages int `json:"minImages"`
MaxImages int `json:"maxImages"`
MaxImageBytes int64 `json:"maxImageBytes"`
MaxVideos int `json:"maxVideos"`
@@ -154,7 +155,7 @@ func defaultImageSizeValues() []string {
func DefaultModelCapabilityConfigForModel(protocol string, modelName string) *ModelCapabilityConfig {
video := &VideoCapabilityConfig{
References: VideoReferenceConfig{PromptMaxChars: 1000, MaxImages: 9, MaxImageBytes: 30 * 1024 * 1024, MaxVideos: 0, MaxVideoBytes: 0, MaxVideoDuration: 0, MaxAudios: 0, MaxAudioBytes: 0, MaxAudioDuration: 0},
References: VideoReferenceConfig{PromptMaxChars: 1000, MinImages: 0, MaxImages: 9, MaxImageBytes: 30 * 1024 * 1024, MaxVideos: 0, MaxVideoBytes: 0, MaxVideoDuration: 0, MaxAudios: 0, MaxAudioBytes: 0, MaxAudioDuration: 0},
Duration: VideoDurationConfig{Selection: "range", Min: 1, Max: 15, Step: 1, Default: 6},
Ratios: []string{"16:9", "9:16", "1:1", "4:3", "3:4", "21:9"},
DefaultRatio: "16:9",
@@ -277,11 +278,14 @@ func validateVideoCapabilityConfig(value *VideoCapabilityConfig) error {
if value.References.PromptMaxChars < 1 || value.References.PromptMaxChars > 1000000 {
return BadAuthRequest("提示词最大字符数必须在 1-1000000 之间")
}
for name, number := range map[string]int{"最大图片引用数": value.References.MaxImages, "最大视频引用数": value.References.MaxVideos, "最大音频引用数": value.References.MaxAudios} {
for name, number := range map[string]int{"最少图片引用数": value.References.MinImages, "最大图片引用数": value.References.MaxImages, "最大视频引用数": value.References.MaxVideos, "最大音频引用数": value.References.MaxAudios} {
if number < 0 || number > 100 {
return BadAuthRequest(name + "必须在 0-100 之间")
}
}
if value.References.MinImages > value.References.MaxImages {
return BadAuthRequest("最少图片引用数不能超过最大图片引用数")
}
if value.References.MaxImageBytes < 0 || value.References.MaxVideoBytes < 0 || value.References.MaxAudioBytes < 0 || value.References.MaxVideoDuration < 0 || value.References.MaxAudioDuration < 0 {
return BadAuthRequest("引用素材限制不能小于 0")
}
@@ -291,8 +295,12 @@ func validateVideoCapabilityConfig(value *VideoCapabilityConfig) error {
if len(value.Ratios) == 0 || strings.TrimSpace(value.DefaultRatio) == "" || !containsCapabilityString(value.Ratios, value.DefaultRatio) {
return BadAuthRequest("请至少配置一个画面比例,并选择默认比例")
}
if len(value.Resolutions) == 0 || strings.TrimSpace(value.DefaultResolution) == "" || !containsCapabilityString(value.Resolutions, value.DefaultResolution) {
return BadAuthRequest("请至少配置一个输出分辨率,并选择默认分辨率")
if len(value.Resolutions) == 0 {
if strings.TrimSpace(value.DefaultResolution) != "" {
return BadAuthRequest("未配置输出分辨率时不能设置默认分辨率")
}
} else if strings.TrimSpace(value.DefaultResolution) == "" || !containsCapabilityString(value.Resolutions, value.DefaultResolution) {
return BadAuthRequest("默认输出分辨率必须属于支持值")
}
if len(value.Operations) == 0 || strings.TrimSpace(value.DefaultOperation) == "" || !containsCapabilityString(value.Operations, value.DefaultOperation) {
return BadAuthRequest("请至少配置一个生成模式,并选择默认模式")
@@ -377,6 +385,9 @@ func validateVideoTask(profile *VideoCapabilityConfig, input canvasGenerationInp
if len(input.ReferenceImages) > profile.References.MaxImages || len(input.ReferenceVideos) > profile.References.MaxVideos || len(input.ReferenceAudios) > profile.References.MaxAudios {
return BadAuthRequest("参考素材数量超过当前模型限制")
}
if len(input.ReferenceImages) < profile.References.MinImages {
return BadAuthRequest(fmt.Sprintf("当前视频模型至少需要 %d 张参考图", profile.References.MinImages))
}
for _, media := range input.ReferenceImages {
if profile.References.MaxImageBytes > 0 && media.Bytes > profile.References.MaxImageBytes {
return BadAuthRequest("参考图片文件超过当前模型大小限制")

View File

@@ -116,3 +116,32 @@ func TestValidateVideoTaskIgnoresGlobalResolutionWhenCatalogDeclaresNone(t *test
t.Fatalf("validateVideoTask() error = %v", err)
}
}
func TestNormalizeVideoCapabilityAllowsOmittedResolution(t *testing.T) {
profile := DefaultModelCapabilityConfigForModel("newapi-channel-2", "endpoint-video").Video
profile.Resolutions = nil
profile.DefaultResolution = ""
result, err := NormalizeModelCapabilityConfig("video", "newapi-channel-2", &ModelCapabilityConfig{Version: 1, Video: profile})
if err != nil {
t.Fatalf("NormalizeModelCapabilityConfig() error = %v", err)
}
if result.Video == nil || len(result.Video.Resolutions) != 0 || result.Video.DefaultResolution != "" {
t.Fatalf("normalized video resolution = %#v", result.Video)
}
}
func TestValidateVideoTaskRequiresDeclaredMinimumImages(t *testing.T) {
profile := DefaultModelCapabilityConfigForModel("newapi-channel-2", "image-required-video").Video
profile.References.MinImages = 1
profile.References.MaxImages = 2
profile.Operations = []string{"image_to_video"}
profile.DefaultOperation = "image_to_video"
err := validateVideoTask(profile, canvasGenerationInput{
Config: providerConfig{Model: "image-required-video", VideoSeconds: "6", Size: "16:9", VQuality: "720"},
})
if err == nil || !strings.Contains(err.Error(), "至少需要 1 张参考图") {
t.Fatalf("validateVideoTask() error = %v", err)
}
}

View File

@@ -1973,7 +1973,10 @@ func newAPIChannel2VideoRequestBody(input canvasGenerationInput) (newAPIVideoReq
seconds = 6
}
ratio := normalizeNewAPIChannel2Ratio(input.Config.Size, modelName)
resolution := normalizeNewAPIChannel2Resolution(input.Config.VQuality, modelName)
resolution := videoResolutionNameRequest(input.VideoCapability, input.Config.VQuality)
if modelName == "grok-video-1.5-1080p" {
resolution = "1080p"
}
body := newAPIVideoRequest{
Model: input.Config.Model,
Prompt: strings.TrimSpace(input.Prompt),

View File

@@ -19,7 +19,7 @@ type newAPIVideoRequest struct {
Prompt string `json:"prompt"`
Seconds string `json:"seconds"`
AspectRatio string `json:"aspect_ratio"`
Resolution string `json:"resolution"`
Resolution string `json:"resolution,omitempty"`
GenerateAudio *bool `json:"generate_audio,omitempty"`
ImageURLs []string `json:"image_urls,omitempty"`
VideoURLs []string `json:"video_urls,omitempty"`

View File

@@ -1278,9 +1278,12 @@ func TestRunNewAPIChannel2VideoTaskDownloadsTemporaryResult(t *testing.T) {
}))
defer server.Close()
profile := DefaultModelCapabilityConfigForModel("newapi-channel-2", "grok-image-video").Video
profile.Resolutions = []string{"720p"}
profile.DefaultResolution = "720p"
result, err := runVideoTask(context.Background(), canvasGenerationInput{
Prompt: "make it move",
Config: providerConfig{BaseURL: server.URL, APIKey: "test-key", Model: "grok-image-video", InterfaceType: "newapi-channel-2", VideoSeconds: "15", Size: "720x1280", VQuality: "high"},
Config: providerConfig{BaseURL: server.URL, APIKey: "test-key", Model: "grok-image-video", InterfaceType: "newapi-channel-2", VideoSeconds: "15", Size: "720x1280", VQuality: "720"},
ReferenceImages: []providerMedia{
{ID: "image-1", DataURL: testReferenceImageDataURL},
{ID: "image-2", DataURL: testReferenceImageDataURL},
@@ -1288,6 +1291,7 @@ func TestRunNewAPIChannel2VideoTaskDownloadsTemporaryResult(t *testing.T) {
ReferenceVideos: []providerMedia{{ID: "video-1", URL: server.URL + "/reference.mp4"}},
ReferenceAudios: []providerMedia{{ID: "audio-1", URL: server.URL + "/reference.mp3"}},
Metadata: map[string]interface{}{"videoEditOperation": "image_to_video"},
VideoCapability: profile,
})
if err != nil {
t.Fatalf("runVideoTask() error = %v", err)
@@ -1354,6 +1358,50 @@ func TestNewAPIChannel2SingleImageModelsRequireOneReference(t *testing.T) {
}
}
func TestNewAPIChannel2SendsOnlyDeclaredResolution(t *testing.T) {
tests := []struct {
name string
model string
quality string
resolutions []string
want string
}{
{name: "catalog omits resolution", model: "endpoint-video", quality: "720"},
{name: "declared 2K alias", model: "declared-video", quality: "2K", resolutions: []string{"1440p"}, want: "1440p"},
{name: "fixed grok 1080p model", model: "grok-video-1.5-1080p", quality: "720", want: "1080p"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
profile := DefaultModelCapabilityConfigForModel("newapi-channel-2", test.model).Video
profile.Resolutions = test.resolutions
profile.DefaultResolution = ""
input := canvasGenerationInput{
Config: providerConfig{Model: test.model, VideoSeconds: "6", Size: "16:9", VQuality: test.quality},
VideoCapability: profile,
}
if strings.HasPrefix(test.model, "grok-video-1.5") {
input.ReferenceImages = []providerMedia{{ID: "image-1", DataURL: testReferenceImageDataURL}}
}
body, err := newAPIChannel2VideoRequestBody(input)
if err != nil {
t.Fatalf("newAPIChannel2VideoRequestBody() error = %v", err)
}
if body.Resolution != test.want {
t.Fatalf("resolution = %q, want %q", body.Resolution, test.want)
}
mapped, err := requestAsMap(body)
if err != nil {
t.Fatalf("requestAsMap() error = %v", err)
}
_, hasResolution := mapped["resolution"]
if hasResolution != (test.want != "") {
t.Fatalf("resolution presence = %v, want %v; body = %#v", hasResolution, test.want != "", mapped)
}
})
}
}
func TestNewAPIChannel2RejectsAudioWithoutReferenceVideo(t *testing.T) {
_, err := newAPIChannel2VideoRequestBody(canvasGenerationInput{
Config: providerConfig{Model: "grok-image-video", VideoSeconds: "6"},

View File

@@ -47,7 +47,8 @@ export function ModelCapabilityEditor({ value, onChange, protocol, capability =
<CapabilityGroup title="引用限制">
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
<NumberField label="提示词字符数" value={profile.references.promptMaxChars} min={1} disabled={disabled} onChange={(value) => updateReferences({ promptMaxChars: value || 1 })} />
<NumberField label="最大图片引用" value={profile.references.maxImages} min={0} disabled={disabled} onChange={(value) => updateReferences({ maxImages: value || 0 })} />
<NumberField label="最少图片引用" value={profile.references.minImages} min={0} max={profile.references.maxImages} disabled={disabled} onChange={(value) => updateReferences({ minImages: value || 0 })} />
<NumberField label="最大图片引用" value={profile.references.maxImages} min={0} disabled={disabled} onChange={(value) => { const maxImages = value || 0; updateReferences({ maxImages, minImages: Math.min(profile.references.minImages, maxImages) }); }} />
<NumberField label="图片上限 MB" value={bytesToMB(profile.references.maxImageBytes)} min={0} disabled={disabled} onChange={(value) => updateReferences({ maxImageBytes: mbToBytes(value) })} />
<NumberField label="最大视频引用" value={profile.references.maxVideos} min={0} disabled={disabled} onChange={(value) => updateReferences({ maxVideos: value || 0 })} />
<NumberField label="视频上限 MB" value={bytesToMB(profile.references.maxVideoBytes)} min={0} disabled={disabled} onChange={(value) => updateReferences({ maxVideoBytes: mbToBytes(value) })} />
@@ -76,7 +77,7 @@ export function ModelCapabilityEditor({ value, onChange, protocol, capability =
<div className="grid gap-2 sm:grid-cols-2">
<Field label="画面比例"><Select mode="multiple" className="w-full" disabled={disabled} value={profile.ratios} options={ratioOptions.map((item) => ({ label: item, value: item }))} onChange={(ratios) => update({ ratios, defaultRatio: ratios.includes(profile.defaultRatio) ? profile.defaultRatio : ratios[0] || "16:9" })} /></Field>
<Field label="默认比例"><Select className="w-full" disabled={disabled} value={profile.defaultRatio} options={profile.ratios.map((item) => ({ label: item, value: item }))} onChange={(defaultRatio) => update({ defaultRatio })} /></Field>
<Field label="输出分辨率"><Select mode="tags" className="w-full" disabled={disabled} value={profile.resolutions} tokenSeparators={[","]} placeholder="选择标准档位或输入 768p 等模型专属值" options={resolutionOptions.map((item) => ({ label: item.toUpperCase(), value: item }))} onChange={(resolutions) => update({ resolutions, defaultResolution: resolutions.includes(profile.defaultResolution) ? profile.defaultResolution : resolutions[0] || "720p" })} /></Field>
<Field label="输出分辨率"><Select mode="tags" className="w-full" disabled={disabled} value={profile.resolutions} tokenSeparators={[","]} placeholder="选择标准档位或输入 768p 等模型专属值" options={resolutionOptions.map((item) => ({ label: item.toUpperCase(), value: item }))} onChange={(resolutions) => update({ resolutions, defaultResolution: resolutions.includes(profile.defaultResolution) ? profile.defaultResolution : resolutions[0] || "" })} /></Field>
<Field label="默认分辨率"><Select className="w-full" disabled={disabled} value={profile.defaultResolution} options={profile.resolutions.map((item) => ({ label: item.toUpperCase(), value: item }))} onChange={(defaultResolution) => update({ defaultResolution })} /></Field>
</div>
<div className="grid gap-2 sm:grid-cols-2">
@@ -163,8 +164,8 @@ function Field({ label, children }: { label: string; children: ReactNode }) {
return <label className="block min-w-0"><span className="mb-1 block text-[var(--fs-tiny)] text-foreground/48">{label}</span>{children}</label>;
}
function NumberField({ label, value, min, disabled, onChange }: { label: string; value?: number; min: number; disabled: boolean; onChange: (value: number | null) => void }) {
return <Field label={label}><InputNumber className="w-full" disabled={disabled} min={min} precision={0} value={value} onChange={onChange} /></Field>;
function NumberField({ label, value, min, max, disabled, onChange }: { label: string; value?: number; min: number; max?: number; disabled: boolean; onChange: (value: number | null) => void }) {
return <Field label={label}><InputNumber className="w-full" disabled={disabled} min={min} max={max} precision={0} value={value} onChange={onChange} /></Field>;
}
function BooleanField({ label, value, disabled, onChange }: { label: string; value: { supported: boolean; default: boolean }; disabled: boolean; onChange: (value: { supported: boolean; default: boolean }) => void }) {

View File

@@ -1,4 +1,4 @@
import { defaultModelCapabilityConfig, type ModelCapabilityConfig, type VideoCapabilityConfig } from "@/lib/model-capabilities";
import { defaultModelCapabilityConfig, type ModelCapabilityConfig } from "@/lib/model-capabilities";
import { modelProtocolCapability, protocolForModelCatalog, type ModelProtocol } from "@/lib/model-protocols";
import type { ModelChannel } from "@/stores/use-config-store";
@@ -66,31 +66,32 @@ export function mergeFetchedChannelModelCosts(channel: ModelChannel, catalog: Ch
const next: ChannelModelCost[] = [];
for (const item of catalog) {
const existing = existingByModel.get(item.id);
const catalogBackedCapability = hasCatalogCapabilityMetadata(item);
const inferredProtocol = protocolForModelCatalog(item.supportedEndpointTypes, item.modelType);
if (existing && !catalogBackedCapability && !inferredProtocol) {
next.push({
...existing,
...(item.displayName ? { displayName: item.displayName } : {}),
});
continue;
}
const protocol = inferredProtocol || existing?.protocol || channel.interfaceType;
const capability = modelProtocolCapability(protocol) || item.modelType;
if (!protocol || !capability) continue;
const protocolChanged = Boolean(existing && inferredProtocol && existing.protocol !== inferredProtocol);
const replaceCapabilityConfig = !existing || catalogBackedCapability || protocolChanged;
const capabilityConfig = replaceCapabilityConfig && (capability === "image" || capability === "video") ? (catalogBackedCapability ? catalogCapabilityConfig(item, protocol, capability) : defaultModelCapabilityConfig(protocol, item.id)) : undefined;
const inferredProtocol = protocolForModelCatalog(item.supportedEndpointTypes);
const inferredCapability = modelProtocolCapability(inferredProtocol) || item.modelType;
if (existing) {
const protocol = inferredProtocol || existing.protocol;
const capability = inferredCapability || existing.capability;
const capabilityChanged = capability !== existing.capability;
const patchCapabilityConfig = hasCatalogCapabilityConfig(item) && (capability === "image" || capability === "video");
const capabilityConfig = patchCapabilityConfig
? catalogCapabilityConfig(item, protocol || channel.interfaceType, capability, capabilityChanged ? undefined : existing.capabilityConfig, false)
: capabilityChanged
? undefined
: existing.capabilityConfig;
next.push({
...existing,
...(item.displayName ? { displayName: item.displayName } : {}),
capability,
protocol,
...(replaceCapabilityConfig ? { capabilityConfig } : {}),
...(inferredProtocol ? { protocol: inferredProtocol } : {}),
...(patchCapabilityConfig || capabilityChanged ? { capabilityConfig } : {}),
});
continue;
}
const capability = inferredCapability || modelProtocolCapability(channel.interfaceType);
const protocol = inferredProtocol || protocolTemplateForNewCatalogModel(capability, channel.interfaceType);
if (!protocol || !capability) continue;
const capabilityConfig = capability === "image" || capability === "video" ? catalogCapabilityConfig(item, protocol, capability, undefined, true) : undefined;
next.push({
model: item.id,
...(item.displayName ? { displayName: item.displayName } : {}),
@@ -104,12 +105,21 @@ export function mergeFetchedChannelModelCosts(channel: ModelChannel, catalog: Ch
return next;
}
function hasCatalogCapabilityMetadata(item: ChannelModelCatalogItem) {
return Boolean(item.modelType || item.supportedEndpointTypes?.length || item.defaultParameters || item.options || item.supportsImages !== undefined || item.minImages !== undefined || item.maxImages !== undefined);
function protocolTemplateForNewCatalogModel(capability: ChannelModelCost["capability"] | undefined, channelProtocol: ModelProtocol | undefined): ModelProtocol | undefined {
if (!capability) return channelProtocol;
if (modelProtocolCapability(channelProtocol) === capability) return channelProtocol;
const templates: Record<ChannelModelCost["capability"], ModelProtocol> = { text: "chat-completion", image: "openai-image", video: "newapi", audio: "openai-audio" };
return templates[capability];
}
function catalogCapabilityConfig(item: ChannelModelCatalogItem, protocol: ModelProtocol, capability: "image" | "video"): ModelCapabilityConfig {
const config = defaultModelCapabilityConfig(protocol, item.id);
function hasCatalogCapabilityConfig(item: ChannelModelCatalogItem) {
return Boolean(item.defaultParameters || item.options || item.supportsImages !== undefined || item.minImages !== undefined || item.maxImages !== undefined);
}
function catalogCapabilityConfig(item: ChannelModelCatalogItem, protocol: ModelProtocol | undefined, capability: "image" | "video", existing: ModelCapabilityConfig | undefined, isNew: boolean): ModelCapabilityConfig {
const fallback = defaultModelCapabilityConfig(protocol, item.id);
const existingProfile = capability === "image" ? existing?.image : existing?.video;
const config = structuredClone(existingProfile ? existing! : fallback);
if (capability === "image") {
if (item.supportsImages === false) config.image!.references.maxImages = 0;
else if (item.maxImages !== undefined) config.image!.references.maxImages = item.maxImages;
@@ -117,6 +127,10 @@ function catalogCapabilityConfig(item: ChannelModelCatalogItem, protocol: ModelP
}
const video = config.video!;
if (isNew) {
video.resolutions = [];
video.defaultResolution = "";
}
const durations = uniqueNumbers(optionValues(item.options?.durationSeconds));
const defaultDuration = positiveNumber(item.defaultParameters?.durationSeconds);
if (durations.length) {
@@ -138,15 +152,26 @@ function catalogCapabilityConfig(item: ChannelModelCatalogItem, protocol: ModelP
// explicitly advertises compatible values. An empty list means omit it.
const resolutions = optionValues(item.options?.resolution);
const defaultResolution = item.defaultParameters?.resolution?.trim() || "";
video.resolutions = resolutions;
video.defaultResolution = resolutions.includes(defaultResolution) ? defaultResolution : resolutions[0] || "";
if (resolutions.length || defaultResolution) {
video.resolutions = resolutions.length ? resolutions : [defaultResolution];
video.defaultResolution = video.resolutions.includes(defaultResolution) ? defaultResolution : video.resolutions[0] || "";
}
if (item.supportsImages === false) {
if (item.supportsImages === false || item.maxImages === 0) {
video.references.minImages = 0;
video.references.maxImages = 0;
video.operations = video.operations.filter((operation) => operation !== "image_to_video");
video.defaultOperation = "text_to_video";
} else if (item.maxImages !== undefined) {
video.references.maxImages = item.maxImages;
if (!video.operations.length) video.operations = ["text_to_video"];
video.defaultOperation = video.operations.includes(video.defaultOperation) ? video.defaultOperation : video.operations[0]!;
} else {
if (item.maxImages !== undefined) video.references.maxImages = item.maxImages;
if (item.minImages !== undefined) video.references.minImages = item.minImages;
if (video.references.minImages > 0) {
video.references.maxImages = Math.max(video.references.minImages, video.references.maxImages);
video.operations = video.operations.filter((operation) => operation !== "text_to_video");
if (!video.operations.includes("image_to_video")) video.operations.unshift("image_to_video");
video.defaultOperation = "image_to_video";
}
}
return config;
}

View File

@@ -35,6 +35,7 @@ export type ImageCapabilityConfig = {
export type VideoCapabilityConfig = {
references: {
promptMaxChars: number;
minImages: number;
maxImages: number;
maxImageBytes: number;
maxVideos: number;
@@ -133,6 +134,7 @@ export function defaultModelCapabilityConfig(protocol?: ModelProtocol, model = "
const video: VideoCapabilityConfig = {
references: {
promptMaxChars: 1000,
minImages: 0,
maxImages: 9,
maxImageBytes: 30 * 1024 * 1024,
maxVideos: 0,
@@ -189,9 +191,11 @@ export function modelCapabilityConfigFor(config: { channels: Array<{ id: string;
const channel = config.channels.find((item) => item.id === channelId) || config.channels.find((item) => item.models.includes(modelName));
const cost = channel?.modelCosts?.find((item) => item.model === modelName);
const fallback = defaultModelCapabilityConfig(cost?.protocol, modelName);
return cost?.capabilityConfig
? { ...fallback, ...cost.capabilityConfig, image: cost.capabilityConfig.image || fallback.image, video: cost.capabilityConfig.video || fallback.video }
: fallback;
if (!cost?.capabilityConfig) return fallback;
const video = cost.capabilityConfig.video
? { ...fallback.video!, ...cost.capabilityConfig.video, references: { ...fallback.video!.references, ...cost.capabilityConfig.video.references } }
: fallback.video;
return { ...fallback, ...cost.capabilityConfig, image: cost.capabilityConfig.image || fallback.image, video };
}
export function normalizeImageValue(profile: ImageCapabilityConfig, value: { size?: string; quality?: string; count?: string; transparentBackground?: string }) {
@@ -234,6 +238,8 @@ export function videoResolutionRequest(profile: VideoCapabilityConfig, value: st
const candidates = [requested];
if (/^\d+$/.test(requested)) candidates.push(`${requested}p`);
if (requested === "low") candidates.push("480p");
if (requested === "2k") candidates.push("1440p");
if (requested === "1440" || requested === "1440p") candidates.push("2k");
if (requested === "4k") candidates.push("2160p");
if (requested === "2160" || requested === "2160p") candidates.push("4k");
const supported = new Map(profile.resolutions.map((resolution) => [resolution.trim().toLowerCase(), resolution.trim()]));

View File

@@ -92,7 +92,7 @@ export function modelProtocolSupportsTokenBilling(capability?: string, protocol?
return capability === "text" || (capability === "video" && protocol === "volcengine-ark-video");
}
export function protocolForModelCatalog(endpointTypes: string[] = [], modelType?: string): ModelProtocol | undefined {
export function protocolForModelCatalog(endpointTypes: string[] = []): ModelProtocol | undefined {
const normalized = new Set(endpointTypes.map((value) => value.trim().toLowerCase()));
if (normalized.has("openai-chat") || normalized.has("chat-completion") || normalized.has("chat")) return "chat-completion";
if (normalized.has("openai-response") || normalized.has("responses")) return "openai-response";
@@ -102,10 +102,6 @@ export function protocolForModelCatalog(endpointTypes: string[] = [], modelType?
if (normalized.has("grok-image")) return "grok-image";
if (normalized.has("gemini-veo") || normalized.has("gemini-video") || normalized.has("veo")) return "gemini-veo";
if (normalized.has("volcengine-ark-video") || normalized.has("volcengine-ark")) return "volcengine-ark-video";
if (modelType?.trim().toLowerCase() === "video") return "newapi";
if (modelType?.trim().toLowerCase() === "image") return "openai-image";
if (modelType?.trim().toLowerCase() === "audio") return "openai-audio";
if (modelType?.trim().toLowerCase() === "text") return "chat-completion";
return undefined;
}

View File

@@ -85,6 +85,7 @@ export async function createVideoGenerationTask(config: AiConfig, prompt: string
function assertVideoCapability(profile: NonNullable<ReturnType<typeof modelCapabilityConfigFor>["video"]>, references: ReferenceImage[], videoReferences: ReferenceVideo[], audioReferences: ReferenceAudio[], seconds: string) {
if (references.length > profile.references.maxImages || videoReferences.length > profile.references.maxVideos || audioReferences.length > profile.references.maxAudios) throw new Error("参考素材数量超过当前模型限制");
if (references.length < profile.references.minImages) throw new Error(`当前视频模型至少需要 ${profile.references.minImages} 张参考图`);
if (!videoDurationAllowed(profile, Number(seconds))) throw new Error("视频时长不在当前模型支持范围内");
if (profile.references.maxImageBytes > 0 && references.some((image) => (image.bytes || 0) > profile.references.maxImageBytes)) throw new Error("参考图片文件超过当前模型大小限制");
for (const video of videoReferences) {
@@ -116,12 +117,13 @@ async function createVideoGenerationsTask(config: ResolvedAiConfig, model: strin
Promise.all(audioReferences.map((item) => resolveVideoGenerationsUrl(item.url, item.storageKey))),
]);
const profile = modelCapabilityConfigFor(config, model).video!;
const resolution = newAPIVideoResolutionRequest(profile, config.vquality, modelOptionName(model));
const payload = {
model: modelOptionName(model),
prompt: prompt.trim(),
seconds: normalizeVideoSeconds(config.videoSeconds),
aspect_ratio: normalizeVideoSize(config.size) || "16:9",
resolution: normalizeVideoResolution(config.vquality),
...(resolution ? { resolution } : {}),
...(profile.generateAudio.supported ? { generate_audio: boolConfig(config.videoGenerateAudio, profile.generateAudio.default) } : {}),
...(imageUrls.length ? { image_urls: imageUrls } : {}),
...(videoUrls.length ? { video_urls: videoUrls } : {}),
@@ -137,6 +139,11 @@ async function createVideoGenerationsTask(config: ResolvedAiConfig, model: strin
}
}
function newAPIVideoResolutionRequest(profile: NonNullable<ReturnType<typeof modelCapabilityConfigFor>["video"]>, value: string, model: string) {
if (model.trim().toLowerCase() === "grok-video-1.5-1080p") return "1080p";
return videoResolutionRequest(profile, value);
}
async function pollVideoGenerationsTask(config: ResolvedAiConfig, task: VideoGenerationTask, options?: RequestOptions): Promise<VideoGenerationTaskState> {
try {
const raw = await channelGet<ApiEnvelope<Record<string, unknown>>>(config, aiApiUrl(config, `/video/generations/${encodeURIComponent(task.id)}`), options);

View File

@@ -127,6 +127,14 @@ describe("public channel model catalog", () => {
const createSource = await Bun.file(new URL("../src/pages/create/index.tsx", import.meta.url)).text();
expect(createSource).toContain('const videoResolutionSupported = props.mode === "video" && resolutions.length > 0;');
expect(createSource).toContain("...(videoResolutionSupported ? [videoResolutionLabel(props.videoQuality)] : [])");
let requestBody: Record<string, unknown> = {};
axios.post = (async (_url: string, body: Record<string, unknown>) => {
requestBody = body;
return { data: { id: "endpoint-video-task" } };
}) as typeof axios.post;
await createVideoGenerationTask(config, "synthetic prompt");
expect(requestBody).not.toHaveProperty("resolution");
});
test("preserves a manually configured capability profile when a catalog only returns an ID", () => {
@@ -180,6 +188,42 @@ describe("public channel model catalog", () => {
expect(costs[0]).toEqual({ ...existing, displayName: "Catalog Video" });
});
test("uses modelType only for capability and preserves an existing manual protocol and profile", () => {
const channel = createModelChannel({
id: "manual-partial",
name: "Manual Partial",
baseUrl: "https://manual.example",
apiKey: "synthetic-test-key",
apiFormat: "openai",
interfaceType: "chat-completion",
models: ["video-x"],
});
const capabilityConfig = defaultModelCapabilityConfig("gemini-veo", "video-x");
capabilityConfig.video!.resolutions = ["1440p"];
capabilityConfig.video!.defaultResolution = "1440p";
channel.modelCosts = [{ model: "video-x", capability: "video", protocol: "gemini-veo", billingMode: "per_second", unitPriceMicrocredits: 7654, capabilityConfig }];
const costs = mergeFetchedChannelModelCosts(channel, [{ id: "video-x", modelType: "video" }]);
expect(costs).toEqual(channel.modelCosts);
});
test("requires the declared minimum image count and selects image-to-video", async () => {
const catalog: ChannelModelCatalogItem = {
id: "image-required-video",
modelType: "video",
minImages: 1,
maxImages: 2,
};
const config = configForCatalog([catalog]);
const profile = config.channels[0]!.modelCosts![0]!.capabilityConfig!.video!;
expect(profile.references).toMatchObject({ minImages: 1, maxImages: 2 });
expect(profile.operations).toEqual(["image_to_video"]);
expect(profile.defaultOperation).toBe("image_to_video");
expect(createVideoGenerationTask(config, "synthetic prompt")).rejects.toThrow("至少需要 1 张参考图");
});
test("preserves an ID-only manual video model cost when both protocol sources are missing", () => {
const channel = createModelChannel({
id: "manual-no-protocol",