fix(models): 模型目录 - 保留人工能力并按声明发送视频分辨率
This commit is contained in:
@@ -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("参考图片文件超过当前模型大小限制")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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"},
|
||||
|
||||
@@ -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 }) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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()]));
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user