fix(models): 模型目录 - 识别公共视频能力并按声明发送分辨率

This commit is contained in:
Codex
2026-08-16 22:27:29 +08:00
parent 85aa4b3ee1
commit 6d6ad67dae
19 changed files with 792 additions and 51 deletions

View File

@@ -29,9 +29,28 @@ type channelModelsPayload struct {
}
type channelModelItem struct {
ID string `json:"id"`
Name string `json:"name"`
SupportedEndpointTypes []string `json:"supported_endpoint_types"`
ID string `json:"id"`
Name string `json:"name"`
DisplayName string `json:"display_name"`
ModelType string `json:"model_type"`
SupportedEndpointTypes []string `json:"supported_endpoint_types"`
DefaultParameters channelModelCatalogParameters `json:"default_parameters"`
Options channelModelCatalogOptions `json:"options"`
SupportsImages *bool `json:"supports_images"`
MinImages *int `json:"min_images"`
MaxImages *int `json:"max_images"`
}
type channelModelCatalogParameters struct {
AspectRatio string `json:"aspect_ratio"`
DurationSeconds string `json:"duration_seconds"`
Resolution string `json:"resolution"`
}
type channelModelCatalogOptions struct {
AspectRatio []ChannelModelCatalogOption `json:"aspect_ratio"`
DurationSeconds []ChannelModelCatalogOption `json:"duration_seconds"`
Resolution []ChannelModelCatalogOption `json:"resolution"`
}
func (s *Service) FetchChannelModels(ctx context.Context, actor *model.User, input ChannelModelsRequest) ([]string, error) {
@@ -54,10 +73,34 @@ func (s *Service) FetchChannelModels(ctx context.Context, actor *model.User, inp
}
// ChannelModelCatalogItem 是前端自定义渠道拉取模型目录后的最小合同;
// supportedEndpointTypes 由上游 /models 返回,前端据此推导能力与协议,不依赖模型名猜测。
// 协议、能力和可选参数均来自上游公开元数据,不展开供应商内部兼容模型。
type ChannelModelCatalogItem struct {
ID string `json:"id"`
SupportedEndpointTypes []string `json:"supportedEndpointTypes,omitempty"`
ID string `json:"id"`
DisplayName string `json:"displayName,omitempty"`
ModelType string `json:"modelType,omitempty"`
SupportedEndpointTypes []string `json:"supportedEndpointTypes,omitempty"`
DefaultParameters ChannelModelCatalogDefaultParameters `json:"defaultParameters,omitempty"`
Options ChannelModelCatalogOptions `json:"options,omitempty"`
SupportsImages *bool `json:"supportsImages,omitempty"`
MinImages *int `json:"minImages,omitempty"`
MaxImages *int `json:"maxImages,omitempty"`
}
type ChannelModelCatalogDefaultParameters struct {
AspectRatio string `json:"aspectRatio,omitempty"`
DurationSeconds string `json:"durationSeconds,omitempty"`
Resolution string `json:"resolution,omitempty"`
}
type ChannelModelCatalogOptions struct {
AspectRatio []ChannelModelCatalogOption `json:"aspectRatio,omitempty"`
DurationSeconds []ChannelModelCatalogOption `json:"durationSeconds,omitempty"`
Resolution []ChannelModelCatalogOption `json:"resolution,omitempty"`
}
type ChannelModelCatalogOption struct {
Value string `json:"value"`
Label string `json:"label,omitempty"`
}
func (s *Service) FetchChannelModelCatalog(ctx context.Context, actor *model.User, input ChannelModelsRequest) ([]ChannelModelCatalogItem, error) {
@@ -134,7 +177,25 @@ func (s *Service) FetchChannelModelCatalog(ctx context.Context, actor *model.Use
continue
}
seen[name] = true
catalog = append(catalog, ChannelModelCatalogItem{ID: name, SupportedEndpointTypes: normalizeCatalogEndpointTypes(item.SupportedEndpointTypes)})
catalog = append(catalog, ChannelModelCatalogItem{
ID: name,
DisplayName: strings.TrimSpace(item.DisplayName),
ModelType: normalizeCatalogModelType(item.ModelType),
SupportedEndpointTypes: normalizeCatalogEndpointTypes(item.SupportedEndpointTypes),
DefaultParameters: ChannelModelCatalogDefaultParameters{
AspectRatio: strings.TrimSpace(item.DefaultParameters.AspectRatio),
DurationSeconds: strings.TrimSpace(item.DefaultParameters.DurationSeconds),
Resolution: strings.TrimSpace(item.DefaultParameters.Resolution),
},
Options: ChannelModelCatalogOptions{
AspectRatio: normalizeCatalogOptions(item.Options.AspectRatio),
DurationSeconds: normalizeCatalogOptions(item.Options.DurationSeconds),
Resolution: normalizeCatalogOptions(item.Options.Resolution),
},
SupportsImages: item.SupportsImages,
MinImages: item.MinImages,
MaxImages: item.MaxImages,
})
}
sort.Slice(catalog, func(left int, right int) bool {
return catalog[left].ID < catalog[right].ID
@@ -142,6 +203,30 @@ func (s *Service) FetchChannelModelCatalog(ctx context.Context, actor *model.Use
return catalog, nil
}
func normalizeCatalogModelType(value string) string {
normalized := strings.ToLower(strings.TrimSpace(value))
switch normalized {
case "text", "image", "video", "audio":
return normalized
default:
return ""
}
}
func normalizeCatalogOptions(options []ChannelModelCatalogOption) []ChannelModelCatalogOption {
seen := make(map[string]bool, len(options))
normalized := make([]ChannelModelCatalogOption, 0, len(options))
for _, option := range options {
value := strings.TrimSpace(option.Value)
if value == "" || seen[value] {
continue
}
seen[value] = true
normalized = append(normalized, ChannelModelCatalogOption{Value: value, Label: strings.TrimSpace(option.Label)})
}
return normalized
}
func normalizeCatalogEndpointTypes(values []string) []string {
seen := make(map[string]bool, len(values))
normalized := make([]string, 0, len(values))

View File

@@ -5,6 +5,7 @@ import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
@@ -101,6 +102,66 @@ func TestCustomModelCatalogConsumesAllowLocalChannelAndServerCapability(t *testi
}
}
func TestCustomModelCatalogPreservesPublicCapabilityMetadataWithoutCompatibilityIDs(t *testing.T) {
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"data":[
{"id":"image-fast","display_name":"Image Fast","model_type":"image"},
{"id":"image-quality","display_name":"Image Quality","model_type":"image"},
{"id":"omni","capability_id":"omni-flash","display_name":"Omni Flash","model_type":"video","supports_images":false,"min_images":0,"max_images":0,"default_parameters":{"aspect_ratio":"16:9","duration_seconds":"10"},"options":{"aspect_ratio":[{"value":"16:9"},{"value":"9:16"}],"duration_seconds":[{"value":"8"},{"value":"10"}]},"compatibility_map":[{"parameters":{"aspect_ratio":"9:16","duration_seconds":"10"},"model_id":"omni_portrait_10s"}]},
{"id":"veo-lite","display_name":"Veo Lite","model_type":"video"},
{"id":"veo-fast","display_name":"Veo Fast","model_type":"video"},
{"id":"veo-quality","display_name":"Veo Quality","model_type":"video"}
]}`))
}))
defer upstream.Close()
svc := &Service{runtimeCapabilities: RuntimeCapabilities{desktopLocalChannels: true}}
catalog, err := svc.FetchChannelModelCatalog(context.Background(), &model.User{ID: "user-1"}, ChannelModelsRequest{
BaseURL: upstream.URL, APIKey: "test-key", APIFormat: "openai", AllowLocalChannel: true,
})
if err != nil {
t.Fatalf("FetchChannelModelCatalog() error = %v", err)
}
if len(catalog) != 6 {
t.Fatalf("catalog length = %d; want 6", len(catalog))
}
var omni *ChannelModelCatalogItem
for index := range catalog {
if catalog[index].ID == "omni" {
omni = &catalog[index]
break
}
}
if omni == nil {
t.Fatal("catalog is missing public omni capability")
}
if omni.DisplayName != "Omni Flash" || omni.ModelType != "video" {
t.Fatalf("omni metadata = %#v", omni)
}
if omni.DefaultParameters.AspectRatio != "16:9" || omni.DefaultParameters.DurationSeconds != "10" {
t.Fatalf("omni defaults = %#v", omni.DefaultParameters)
}
if got := optionValues(omni.Options.DurationSeconds); len(got) != 2 || got[0] != "8" || got[1] != "10" {
t.Fatalf("omni duration options = %#v", got)
}
encoded, err := json.Marshal(catalog)
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(encoded), "omni_portrait_10s") || strings.Contains(string(encoded), "compatibility") {
t.Fatalf("ordinary catalog exposed compatibility IDs: %s", encoded)
}
}
func optionValues(options []ChannelModelCatalogOption) []string {
values := make([]string, 0, len(options))
for _, option := range options {
values = append(values, option.Value)
}
return values
}
func TestSystemWorkerRestartReevaluatesStoredLocalFlagAgainstServerCapability(t *testing.T) {
db, err := gorm.Open(sqlite.Open("file:"+newID()+"?mode=memory&cache=shared"), &gorm.Config{})
if err != nil {

View File

@@ -405,7 +405,7 @@ func validateVideoTask(profile *VideoCapabilityConfig, input canvasGenerationInp
if input.Config.Size != "" && !videoRatioAllowed(profile.Ratios, input.Config.Size) {
return BadAuthRequest("画面比例不在当前模型支持范围内")
}
if input.Config.VQuality != "" && !containsCapabilityString(profile.Resolutions, normalizeResolution(input.Config.VQuality)) {
if len(profile.Resolutions) > 0 && !isAutomaticVideoResolution(input.Config.VQuality) && videoResolutionNameRequest(profile, input.Config.VQuality) == "" {
return BadAuthRequest("输出分辨率不在当前模型支持范围内")
}
operation := metadataString(input.Metadata, "videoEditOperation")

View File

@@ -97,3 +97,22 @@ func TestNormalizeResolutionSupportsCommonAliases(t *testing.T) {
}
}
}
func TestValidateVideoTaskIgnoresGlobalResolutionWhenCatalogDeclaresNone(t *testing.T) {
profile := DefaultModelCapabilityConfigForModel("newapi", "omni").Video
profile.Duration = VideoDurationConfig{Selection: "enum", Values: []int{8, 10}, Default: 10}
profile.Ratios = []string{"16:9", "9:16"}
profile.DefaultRatio = "16:9"
profile.Resolutions = nil
profile.DefaultResolution = ""
profile.References.MaxImages = 0
profile.Operations = []string{"text_to_video"}
profile.DefaultOperation = "text_to_video"
err := validateVideoTask(profile, canvasGenerationInput{
Config: providerConfig{Model: "omni", VideoSeconds: "10", Size: "16:9", VQuality: "720"},
})
if err != nil {
t.Fatalf("validateVideoTask() error = %v", err)
}
}

View File

@@ -1420,7 +1420,9 @@ func runVideoTask(ctx context.Context, input canvasGenerationInput) (map[string]
if size := normalizeVideoSize(input.Config.Size); size != "" {
writeField(writer, "size", size)
}
writeField(writer, "resolution_name", normalizeVideoResolution(input.Config.VQuality))
if resolution := videoResolutionNameRequest(input.VideoCapability, input.Config.VQuality); resolution != "" {
writeField(writer, "resolution_name", resolution)
}
writeField(writer, "preset", "normal")
if shouldSendNewAPIVideoImages(input) {
for _, image := range input.ReferenceImages {

View File

@@ -640,6 +640,64 @@ func TestRunVideoTaskUsesNewAPIForAnyVideoModel(t *testing.T) {
}
}
func TestRunVideoTaskSendsOnlyDeclaredResolutionName(t *testing.T) {
tests := []struct {
name string
resolutions []string
quality string
withoutProfile bool
want string
}{
{name: "catalog omits resolution capability", quality: "720"},
{name: "auto never invents 720p", resolutions: []string{"720p", "1080p"}, quality: "auto"},
{name: "legacy auto never invents 720p", quality: "auto", withoutProfile: true},
{name: "missing profile explicit 720 never invents resolution", quality: "720", withoutProfile: true},
{name: "declared HD resolution", resolutions: []string{"1080p"}, quality: "1080", want: "1080p"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
t.Setenv("CANVAS_ALLOW_PRIVATE_UPSTREAMS", "true")
var got string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.Method + " " + r.URL.Path {
case "POST /v1/videos":
if err := r.ParseMultipartForm(1 << 20); err != nil {
t.Fatalf("ParseMultipartForm() error = %v", err)
}
got = r.FormValue("resolution_name")
_, _ = w.Write([]byte(`{"id":"video-resolution","status":"queued"}`))
case "GET /v1/videos/video-resolution":
_, _ = w.Write([]byte(`{"id":"video-resolution","status":"completed"}`))
case "GET /v1/videos/video-resolution/content":
w.Header().Set("Content-Type", "video/mp4")
_, _ = w.Write([]byte("video"))
default:
http.NotFound(w, r)
}
}))
defer server.Close()
profile := DefaultModelCapabilityConfigForModel("newapi", "public-video").Video
profile.Resolutions = test.resolutions
profile.DefaultResolution = ""
if test.withoutProfile {
profile = nil
}
_, err := runVideoTask(context.Background(), canvasGenerationInput{
Prompt: "synthetic prompt",
Config: providerConfig{BaseURL: server.URL + "/v1", APIKey: "test-key", Model: "public-video", VideoSeconds: "8", Size: "16:9", VQuality: test.quality},
VideoCapability: profile,
})
if err != nil {
t.Fatalf("runVideoTask() error = %v", err)
}
if got != test.want {
t.Fatalf("resolution_name = %q; want %q", got, test.want)
}
})
}
}
func TestRunVideoTaskUsesNestedURLBeforeResultURL(t *testing.T) {
t.Setenv("CANVAS_ALLOW_PRIVATE_UPSTREAMS", "true")
paths := make([]string, 0, 3)

View File

@@ -169,6 +169,43 @@ func normalizeVideoResolution(value string) string {
return value + "p"
}
func videoResolutionNameRequest(profile *VideoCapabilityConfig, value string) string {
requested := strings.ToLower(strings.TrimSpace(value))
if requested == "" || requested == "auto" || requested == "default" || requested == "medium" || requested == "high" {
return ""
}
if profile == nil {
return ""
}
if len(profile.Resolutions) == 0 {
return ""
}
candidates := []string{requested, strings.ToLower(normalizeVideoResolution(requested))}
if requested == "4k" {
candidates = append(candidates, "2160p")
}
if requested == "2160" || requested == "2160p" {
candidates = append(candidates, "4k")
}
for _, supported := range profile.Resolutions {
for _, candidate := range candidates {
if strings.EqualFold(strings.TrimSpace(supported), candidate) {
return strings.TrimSpace(supported)
}
}
}
return ""
}
func isAutomaticVideoResolution(value string) bool {
switch strings.ToLower(strings.TrimSpace(value)) {
case "", "auto", "default", "medium", "high":
return true
default:
return false
}
}
func normalizeXAIVideoDuration(value string) int {
duration, err := strconv.Atoi(strings.TrimSpace(value))
if err != nil || duration <= 0 {

View File

@@ -7,7 +7,7 @@
"dev": "vite --host 0.0.0.0 --port 3000",
"build": "tsc --noEmit && vite build",
"typecheck": "tsc --noEmit",
"test": "bun test test/agent-prompt-cache.test.ts test/canvas-batch-connection.test.ts test/canvas-character-reference.test.ts test/canvas-document.test.ts test/canvas-drawing-engine.test.ts test/canvas-emotion-edit-plan.test.ts test/canvas-generation-layout.test.ts test/canvas-image-batch-retry.test.ts test/canvas-leafer-viewport.test.ts test/canvas-local-runtime.test.ts test/canvas-model-policy.test.ts test/canvas-resource-references.test.ts test/canvas-storyboard-references.test.ts test/canvas-video-regeneration.test.ts test/canvas-video-timeline-segments.test.ts test/create-canvas-handoff.test.ts test/create-library-button.test.ts test/creation-references.test.ts test/generation-asset-repository.node.test.ts test/generation-storage-consistency.test.ts test/generation-task-durability.node.test.ts test/generation-task-local.test.ts test/generation-task-materializer.test.ts test/user-scoped-generation-persistence.test.ts test/local-channel-permission.test.ts test/local-channel-runtime-projection.node.test.mjs test/local-channel-task-center.node.test.mjs test/local-channel-ui.node.test.mjs test/local-cli-settings.test.ts test/local-dreamina-cli.test.ts test/local-dreamina-generation.test.ts test/local-dreamina-model-catalog.test.ts test/local-runtime-bootstrap.test.ts test/local-runtime-connection.test.ts test/local-runtime.test.ts test/client-id.test.ts test/grok-image-prompt-limit.test.ts test/image-resolution-tiers.test.ts test/timeline-to-ffmpeg.test.ts test/canvas-video-segment-args.test.ts && bun test test/local-dreamina-cross-runtime.test.ts",
"test": "bun test test/channel-model-catalog.test.ts test/agent-prompt-cache.test.ts test/canvas-batch-connection.test.ts test/canvas-character-reference.test.ts test/canvas-document.test.ts test/canvas-drawing-engine.test.ts test/canvas-emotion-edit-plan.test.ts test/canvas-generation-layout.test.ts test/canvas-image-batch-retry.test.ts test/canvas-leafer-viewport.test.ts test/canvas-local-runtime.test.ts test/canvas-model-policy.test.ts test/canvas-resource-references.test.ts test/canvas-storyboard-references.test.ts test/canvas-video-regeneration.test.ts test/canvas-video-timeline-segments.test.ts test/create-canvas-handoff.test.ts test/create-library-button.test.ts test/creation-references.test.ts test/generation-asset-repository.node.test.ts test/generation-storage-consistency.test.ts test/generation-task-durability.node.test.ts test/generation-task-local.test.ts test/generation-task-materializer.test.ts test/user-scoped-generation-persistence.test.ts test/local-channel-permission.test.ts test/local-channel-runtime-projection.node.test.mjs test/local-channel-task-center.node.test.mjs test/local-channel-ui.node.test.mjs test/local-cli-settings.test.ts test/local-dreamina-cli.test.ts test/local-dreamina-generation.test.ts test/local-dreamina-model-catalog.test.ts test/local-runtime-bootstrap.test.ts test/local-runtime-connection.test.ts test/local-runtime.test.ts test/client-id.test.ts test/grok-image-prompt-limit.test.ts test/image-resolution-tiers.test.ts test/timeline-to-ffmpeg.test.ts test/canvas-video-segment-args.test.ts && bun test test/local-dreamina-cross-runtime.test.ts",
"start": "vite preview --host 0.0.0.0 --port 3000",
"format": "prettier --write .",
"format:check": "prettier --check ."

View File

@@ -5,6 +5,7 @@ import { Button } from "antd";
import { VideoSettingsPanel, videoResolutionLabel, videoSecondsLabel, videoSizeLabel } from "@/components/video-settings-panel";
import { canvasThemes } from "@/lib/canvas-theme";
import { modelCapabilityConfigFor } from "@/lib/model-capabilities";
import { useThemeStore } from "@/stores/use-theme-store";
import type { AiConfig } from "@/stores/use-config-store";
@@ -21,7 +22,12 @@ export function CanvasVideoSettingsPopover({ config, onConfigChange, buttonClass
const panelRef = useRef<HTMLDivElement>(null);
const [open, setOpen] = useState(false);
const [buttonRect, setButtonRect] = useState<DOMRect | null>(null);
const summary = `${videoResolutionLabel(config.vquality)} · ${videoSizeLabel(config.size)} · ${videoSecondsLabel(config.videoSeconds)}`;
const resolutionSupported = Boolean(modelCapabilityConfigFor(config, config.model).video?.resolutions.length);
const summary = [
...(resolutionSupported ? [videoResolutionLabel(config.vquality)] : []),
videoSizeLabel(config.size),
videoSecondsLabel(config.videoSeconds),
].join(" · ");
useEffect(() => {
if (!open) return;

View File

@@ -50,7 +50,7 @@ export function VideoSettingsPanel({ config, onConfigChange, theme, showTitle =
<ImageSettingsTheme theme={theme}>
<div className={className} style={{ color: theme.node.text }} onMouseDown={(event) => event.stopPropagation()}>
{showTitle ? <div className="text-sm font-semibold">视频设置</div> : null}
<SettingGroup title="分辨率" color={theme.node.muted}>
{configuredResolutions.length ? <SettingGroup title="分辨率" color={theme.node.muted}>
<div className="grid grid-cols-3 gap-1.5">
{configuredResolutions.map((item) => (
<OptionPill key={item.value} selected={resolution === item.value} theme={theme} onClick={() => onConfigChange("vquality", item.value)}>
@@ -58,7 +58,7 @@ export function VideoSettingsPanel({ config, onConfigChange, theme, showTitle =
</OptionPill>
))}
</div>
</SettingGroup>
</SettingGroup> : null}
<SettingGroup title="尺寸" color={theme.node.muted}>
<div className="grid grid-cols-[1fr_auto_1fr] items-center gap-1.5">
<DimensionInput prefix="W" value={dimensions.width} disabled={size === "auto"} theme={theme} onChange={(value) => updateDimension("width", value)} />

View File

@@ -0,0 +1,221 @@
import { defaultModelCapabilityConfig, type ModelCapabilityConfig, type VideoCapabilityConfig } from "@/lib/model-capabilities";
import { modelProtocolCapability, protocolForModelCatalog, type ModelProtocol } from "@/lib/model-protocols";
import type { ModelChannel } from "@/stores/use-config-store";
export type ChannelModelCatalogOption = { value: string; label?: string };
export type ChannelModelCatalogItem = {
id: string;
displayName?: string;
modelType?: "text" | "image" | "video" | "audio";
supportedEndpointTypes?: string[];
defaultParameters?: {
aspectRatio?: string;
durationSeconds?: string;
resolution?: string;
};
options?: {
aspectRatio?: ChannelModelCatalogOption[];
durationSeconds?: ChannelModelCatalogOption[];
resolution?: ChannelModelCatalogOption[];
};
supportsImages?: boolean;
minImages?: number;
maxImages?: number;
};
type ChannelModelCost = NonNullable<ModelChannel["modelCosts"]>[number];
export function sanitizeChannelModelCatalogItem(value: unknown): ChannelModelCatalogItem | null {
if (!value || typeof value !== "object") return null;
const record = value as Record<string, unknown>;
const id = stringValue(record.id);
if (!id) return null;
const modelType = stringValue(record.modelType).toLowerCase();
const defaultParameters = objectValue(record.defaultParameters);
const options = objectValue(record.options);
const normalizedDefaults = defaultParameters
? {
...(stringValue(defaultParameters.aspectRatio) ? { aspectRatio: stringValue(defaultParameters.aspectRatio) } : {}),
...(stringValue(defaultParameters.durationSeconds) ? { durationSeconds: stringValue(defaultParameters.durationSeconds) } : {}),
...(stringValue(defaultParameters.resolution) ? { resolution: stringValue(defaultParameters.resolution) } : {}),
}
: undefined;
const normalizedOptions = options
? {
...(catalogOptions(options.aspectRatio).length ? { aspectRatio: catalogOptions(options.aspectRatio) } : {}),
...(catalogOptions(options.durationSeconds).length ? { durationSeconds: catalogOptions(options.durationSeconds) } : {}),
...(catalogOptions(options.resolution).length ? { resolution: catalogOptions(options.resolution) } : {}),
}
: undefined;
return compactCatalogItem({
id,
displayName: stringValue(record.displayName),
modelType: ["text", "image", "video", "audio"].includes(modelType) ? (modelType as ChannelModelCatalogItem["modelType"]) : undefined,
supportedEndpointTypes: stringArray(record.supportedEndpointTypes),
defaultParameters: normalizedDefaults,
options: normalizedOptions,
supportsImages: typeof record.supportsImages === "boolean" ? record.supportsImages : undefined,
minImages: nonNegativeInteger(record.minImages),
maxImages: nonNegativeInteger(record.maxImages),
});
}
export function mergeFetchedChannelModelCosts(channel: ModelChannel, catalog: ChannelModelCatalogItem[]): ChannelModelCost[] {
const existingByModel = new Map((channel.modelCosts || []).map((cost) => [cost.model, cost]));
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;
if (existing) {
next.push({
...existing,
...(item.displayName ? { displayName: item.displayName } : {}),
capability,
protocol,
...(replaceCapabilityConfig ? { capabilityConfig } : {}),
});
continue;
}
next.push({
model: item.id,
...(item.displayName ? { displayName: item.displayName } : {}),
capability,
protocol,
billingMode: "fixed_request",
unitPriceMicrocredits: 0,
...(capabilityConfig ? { capabilityConfig } : {}),
});
}
return next;
}
function hasCatalogCapabilityMetadata(item: ChannelModelCatalogItem) {
return Boolean(
item.modelType ||
item.defaultParameters ||
item.options ||
item.supportsImages !== undefined ||
item.minImages !== undefined ||
item.maxImages !== undefined,
);
}
function catalogCapabilityConfig(item: ChannelModelCatalogItem, protocol: ModelProtocol, capability: "image" | "video"): ModelCapabilityConfig {
const config = defaultModelCapabilityConfig(protocol, item.id);
if (capability === "image") {
if (item.supportsImages === false) config.image!.references.maxImages = 0;
else if (item.maxImages !== undefined) config.image!.references.maxImages = item.maxImages;
return config;
}
const video = config.video!;
const durations = uniqueNumbers(optionValues(item.options?.durationSeconds));
const defaultDuration = positiveNumber(item.defaultParameters?.durationSeconds);
if (durations.length) {
video.duration = { selection: "enum", values: durations, default: durations.includes(defaultDuration) ? defaultDuration : durations[0]! };
} else if (defaultDuration > 0) {
video.duration = { selection: "enum", values: [defaultDuration], default: defaultDuration };
}
const ratios = optionValues(item.options?.aspectRatio);
const defaultRatio = item.defaultParameters?.aspectRatio?.trim() || "";
if (ratios.length) {
video.ratios = ratios;
video.defaultRatio = ratios.includes(defaultRatio) ? defaultRatio : ratios[0]!;
} else if (defaultRatio) {
video.ratios = [defaultRatio];
video.defaultRatio = defaultRatio;
}
// A catalog-backed video may only emit resolution_name when the provider
// 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 (item.supportsImages === false) {
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;
}
return config;
}
function compactCatalogItem(item: ChannelModelCatalogItem): ChannelModelCatalogItem {
const defaultParameters = item.defaultParameters && Object.values(item.defaultParameters).some(Boolean) ? item.defaultParameters : undefined;
const options = item.options && Object.values(item.options).some((values) => values?.length) ? item.options : undefined;
return {
id: item.id,
...(item.displayName ? { displayName: item.displayName } : {}),
...(item.modelType ? { modelType: item.modelType } : {}),
...(item.supportedEndpointTypes?.length ? { supportedEndpointTypes: item.supportedEndpointTypes } : {}),
...(defaultParameters ? { defaultParameters } : {}),
...(options ? { options } : {}),
...(item.supportsImages !== undefined ? { supportsImages: item.supportsImages } : {}),
...(item.minImages !== undefined ? { minImages: item.minImages } : {}),
...(item.maxImages !== undefined ? { maxImages: item.maxImages } : {}),
};
}
function objectValue(value: unknown) {
return value && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : null;
}
function stringValue(value: unknown) {
return typeof value === "string" ? value.trim() : "";
}
function stringArray(value: unknown) {
return Array.isArray(value) ? Array.from(new Set(value.map(stringValue).filter(Boolean))) : [];
}
function catalogOptions(value: unknown): ChannelModelCatalogOption[] {
if (!Array.isArray(value)) return [];
const seen = new Set<string>();
const options: ChannelModelCatalogOption[] = [];
for (const raw of value) {
const record = objectValue(raw);
const optionValue = stringValue(record?.value);
if (!optionValue || seen.has(optionValue)) continue;
seen.add(optionValue);
const label = stringValue(record?.label);
options.push({ value: optionValue, ...(label ? { label } : {}) });
}
return options;
}
function optionValues(options: ChannelModelCatalogOption[] | undefined) {
return Array.from(new Set((options || []).map((option) => option.value.trim()).filter(Boolean)));
}
function positiveNumber(value: unknown) {
const parsed = Number(value);
return Number.isFinite(parsed) && parsed > 0 ? parsed : 0;
}
function uniqueNumbers(values: string[]) {
return Array.from(new Set(values.map(positiveNumber).filter((value) => value > 0)));
}
function nonNegativeInteger(value: unknown) {
return typeof value === "number" && Number.isFinite(value) && value >= 0 ? Math.floor(value) : undefined;
}

View File

@@ -228,6 +228,22 @@ export function normalizeVideoValue(profile: VideoCapabilityConfig, value: { sec
return { seconds: String(duration), ratio, resolution };
}
export function videoResolutionRequest(profile: VideoCapabilityConfig, value: string | undefined) {
const requested = String(value || "").trim().toLowerCase();
if (!requested || requested === "auto" || requested === "default" || requested === "medium" || requested === "high") return undefined;
const candidates = [requested];
if (/^\d+$/.test(requested)) candidates.push(`${requested}p`);
if (requested === "low") candidates.push("480p");
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()]));
for (const candidate of candidates) {
const match = supported.get(candidate);
if (match) return match;
}
return undefined;
}
function normalizeRangeDuration(profile: VideoCapabilityConfig, value: number) {
const min = profile.duration.min || 1;
const max = profile.duration.max || min;

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[] = []): ModelProtocol | undefined {
export function protocolForModelCatalog(endpointTypes: string[] = [], modelType?: 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,6 +102,10 @@ export function protocolForModelCatalog(endpointTypes: string[] = []): ModelProt
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

@@ -1153,15 +1153,16 @@ function GenerationSettingsMenu(props: ComposerProps) {
const nextSize = imageSizeForResolution(imageResolutionOptions, choice, activeImageRatio) || imageResolutionOptions.find((item) => item.tier === choice)?.size;
if (nextSize) props.setRatio(nextSize);
};
const videoResolutionSupported = props.mode === "video" && resolutions.length > 0;
const imageSummary = [
...(props.imageProfile.size.parameter !== "none" ? [usesImageResolutionPicker ? formatImageResolutionSize(props.ratio, imageResolutionOptions) : props.ratio] : []),
...(props.imageProfile.quality.supported ? [qualityLabel] : []),
...(props.imageProfile.maxOutputs > 1 ? [props.count] : []),
].join(" · ");
const summary = props.mode === "video" ? `${props.ratio} · ${videoResolutionLabel(props.videoQuality)}` : imageSummary;
const summary = props.mode === "video" ? [props.ratio, ...(videoResolutionSupported ? [videoResolutionLabel(props.videoQuality)] : [])].join(" · ") : imageSummary;
const panel = <div className="creation-parameter-menu">
{props.mode === "video" || props.imageProfile.size.parameter !== "none" ? <SettingSection title="画幅" value={props.mode === "image" && usesImageResolutionPicker ? activeImageRatio : props.ratio}><div className="creation-parameter-content"><div className="creation-choice-grid is-ratio">{ratios.map((value) => { const selected = props.mode === "image" && usesImageResolutionPicker ? value === activeImageRatio : value === props.ratio; return <button key={value} type="button" aria-pressed={selected} className={selected ? "is-selected" : ""} onClick={() => { if (props.mode === "image") selectImageRatio(value); else props.setRatio(value); setCustomRatioOpen(false); }}><span className="creation-ratio-preview"><span style={ratioPreviewStyle(value)} /></span><span>{value}</span></button>; })}</div>{props.mode !== "video" && props.imageProfile.size.allowCustom && (customRatioOpen ? <label className="creation-custom-value"><span>宽 x 高</span><input value={props.ratio} onFocus={(event) => event.currentTarget.select()} onChange={(event) => props.setRatio(event.target.value)} placeholder="1920x1080 或 2:1" aria-label="自定义图片尺寸或比例" /></label> : <button type="button" className="creation-custom-trigger" onClick={() => setCustomRatioOpen(true)}><Plus />输入自定义尺寸</button>)}</div></SettingSection> : null}
{props.mode === "video" ? <SettingSection title="清晰度" value={videoResolutionLabel(props.videoQuality)}><div className="creation-choice-grid is-resolution">{resolutions.map((option) => <button key={option.value} type="button" aria-pressed={option.value === props.videoQuality} className={option.value === props.videoQuality ? "is-selected" : ""} onClick={() => props.setVideoQuality(option.value)}>{option.label}</button>)}</div></SettingSection> : <>
{props.mode === "video" ? (videoResolutionSupported ? <SettingSection title="清晰度" value={videoResolutionLabel(props.videoQuality)}><div className="creation-choice-grid is-resolution">{resolutions.map((option) => <button key={option.value} type="button" aria-pressed={option.value === props.videoQuality} className={option.value === props.videoQuality ? "is-selected" : ""} onClick={() => props.setVideoQuality(option.value)}>{option.label}</button>)}</div></SettingSection> : null) : <>
{imageResolutionChoiceOptions.length ? <SettingSection title="分辨率" value={activeImageResolutionChoice === "auto" ? "自动" : activeImageResolutionChoice.toUpperCase()}><div className="creation-choice-grid is-resolution">{imageResolutionChoiceOptions.map((choice) => <button key={choice} type="button" aria-pressed={choice === activeImageResolutionChoice} className={choice === activeImageResolutionChoice ? "is-selected" : ""} onClick={() => selectImageResolution(choice)}>{choice === "auto" ? "自动" : choice.toUpperCase()}</button>)}</div></SettingSection> : null}
{props.imageProfile.quality.supported ? <SettingSection title={activeQualityOptions.some((item) => item.value === "1k" || item.value === "2k") ? "分辨率" : "图片质量"} value={qualityLabel}><div className="creation-choice-grid is-quality">{activeQualityOptions.map((option) => <button key={option.value} type="button" aria-pressed={option.value === props.quality} className={option.value === props.quality ? "is-selected" : ""} onClick={() => props.setQuality(option.value)}><span>{option.label}</span><small>{option.description}</small></button>)}</div></SettingSection> : null}
{props.imageProfile.maxOutputs > 1 ? <SettingSection title="生成数量" value={`${props.count} 张`}><div className="creation-parameter-content"><div className="creation-choice-grid is-count">{countOptions.filter((option) => Number(option) <= props.imageProfile.maxOutputs).map((option) => <button key={option} type="button" aria-pressed={option === props.count} className={option === props.count ? "is-selected" : ""} onClick={() => props.setCount(option)}>{option}</button>)}</div><label className="creation-custom-value"><span>自定义</span><input inputMode="numeric" pattern="[0-9]*" value={props.count} onChange={(event) => props.setCount(String(Math.max(1, Math.min(props.imageProfile.maxOutputs, Number(event.target.value) || 1))))} aria-label={`生成数量,范围 1 到 ${props.imageProfile.maxOutputs}`} /><em>张</em></label></div></SettingSection> : null}
@@ -1238,7 +1239,7 @@ function thinkingFor(mode: CreationMode): CreationThinking {
}
function directorNoteFor(mode: CreationMode, settings: CreationSettings): string {
if (mode === "video") return `已按 ${settings.seconds}s · ${videoResolutionLabel(settings.videoQuality)} · ${settings.ratio} 渲染这一镜,等待你的下一句指令。`;
if (mode === "video") return `已按 ${[`${settings.seconds}s`, ...(settings.videoQuality ? [videoResolutionLabel(settings.videoQuality)] : []), settings.ratio].join(" · ")} 渲染这一镜,等待你的下一句指令。`;
if (mode === "image") return `已按 ${settings.ratio} 出图 ${settings.count} 张,等待你的下一句指令。`;
return "";
}

View File

@@ -4,10 +4,9 @@ import { useState } from "react";
import { ChannelHeadersEditor, validateChannelHeaders } from "@/components/channel-headers-editor";
import { WorkspaceState } from "@/components/layout/workspace-state";
import { defaultModelCapabilityConfig } from "@/lib/model-capabilities";
import { modelProtocolCapability, protocolForModelCatalog } from "@/lib/model-protocols";
import { mergeFetchedChannelModelCosts } from "@/lib/channel-model-catalog";
import { desktopLocalChannelFormState, desktopLocalChannelPayloadValue, DESKTOP_LOCAL_CHANNEL_EXAMPLE_BASE_URL } from "@/lib/desktop-local-channel";
import { fetchChannelModels, type ChannelModelCatalogItem } from "@/services/api/image";
import { fetchChannelModels } from "@/services/api/image";
import {
createModelChannel,
defaultBaseUrlForApiFormat,
@@ -21,8 +20,6 @@ import { ChannelModelSettings } from "./channel-video-pricing";
import { useUserStore } from "@/stores/use-user-store";
type UserChannelConnection = "openai" | "gemini";
type ChannelModelCost = NonNullable<ModelChannel["modelCosts"]>[number];
export function ChannelSettingsPane({ onOpenModels }: { onOpenModels: () => void }) {
const { message } = App.useApp();
const config = useConfigStore((state) => state.config);
@@ -108,7 +105,7 @@ export function ChannelSettingsPane({ onOpenModels }: { onOpenModels: () => void
return;
}
updateChannels(
latestConfig.channels.map((item) => (item.id === channel.id ? { ...item, models: result.models, modelCosts: mergeFetchedModelCosts(item, result.catalog) } : item)),
latestConfig.channels.map((item) => (item.id === channel.id ? { ...item, models: result.models, modelCosts: mergeFetchedChannelModelCosts(item, result.catalog) } : item)),
latestConfig,
);
message.success(`${latestChannel.name || "当前渠道"}模型列表已更新`);
@@ -155,7 +152,7 @@ export function ChannelSettingsPane({ onOpenModels }: { onOpenModels: () => void
updateChannels(
latestConfig.channels.map((channel) => {
const fetched = resultMap.get(channel.id);
return fetched ? { ...channel, models: fetched.models, modelCosts: mergeFetchedModelCosts(channel, fetched.catalog) } : channel;
return fetched ? { ...channel, models: fetched.models, modelCosts: mergeFetchedChannelModelCosts(channel, fetched.catalog) } : channel;
}),
latestConfig,
);
@@ -310,25 +307,6 @@ function normalizeDefaultModel(value: string, options: string[]) {
return options.includes(value) ? value : options[0] || "";
}
function mergeFetchedModelCosts(channel: ModelChannel, catalog: ChannelModelCatalogItem[]): ChannelModelCost[] {
const existingByModel = new Map((channel.modelCosts || []).map((cost) => [cost.model, cost]));
const next: ChannelModelCost[] = [];
for (const item of catalog) {
const existing = existingByModel.get(item.id);
const inferredProtocol = protocolForModelCatalog(item.supportedEndpointTypes);
if (existing) {
const inferredCapability = modelProtocolCapability(inferredProtocol);
next.push(inferredProtocol && existing.protocol !== inferredProtocol ? { ...existing, protocol: inferredProtocol, capability: inferredCapability || existing.capability, capabilityConfig: inferredCapability === "image" || inferredCapability === "video" ? defaultModelCapabilityConfig(inferredProtocol, item.id) : undefined } : existing);
continue;
}
const catalogProtocol = inferredProtocol || channel.interfaceType;
const catalogCapability = catalogProtocol ? modelProtocolCapability(catalogProtocol) : undefined;
if (!catalogProtocol || !catalogCapability) continue;
next.push({ model: item.id, capability: catalogCapability, protocol: catalogProtocol, billingMode: "fixed_request", unitPriceMicrocredits: 0, capabilityConfig: catalogCapability === "image" || catalogCapability === "video" ? defaultModelCapabilityConfig(catalogProtocol, item.id) : undefined });
}
return next;
}
function uniqueModels(models: string[]) {
return Array.from(new Set(models.map((model) => model.trim()).filter(Boolean)));
}

View File

@@ -64,14 +64,15 @@ export function ChannelModelSettings({ channel, onChange }: { channel: ModelChan
const protocol = cost?.protocol || defaultProtocolForModel(channel, model);
const capability = cost?.capability || modelProtocolCapability(protocol) || "text";
const billingMode = cost?.billingMode || "fixed_request";
const displayName = cost?.displayName?.trim() || model;
return (
<div key={model} className="flex min-w-0 items-center gap-3 rounded-md bg-surface-active px-3 py-2.5 transition-colors hover:bg-surface-hover">
<span className="grid size-8 shrink-0 place-items-center rounded-md bg-foreground/[.045] text-foreground/65">
<Settings2 className="size-4" />
</span>
<div className="min-w-0 flex-1">
<div className="truncate text-xs font-medium" title={model}>
{model}
<div className="truncate text-xs font-medium" title={displayName === model ? model : `${displayName} (${model})`}>
{displayName}
</div>
<div className="mt-1 flex min-w-0 flex-wrap items-center gap-1.5">
<Tag className="mr-0 text-[var(--fs-tiny)]" bordered={false}>

View File

@@ -1,6 +1,7 @@
import axios from "axios";
import { buildApiUrl, isSystemProxyBaseUrl, resolveBackendApiUrl, resolveModelRequestConfig, type AiConfig, type ModelChannel } from "@/stores/use-config-store";
import { sanitizeChannelModelCatalogItem, type ChannelModelCatalogItem } from "@/lib/channel-model-catalog";
import { nanoid } from "nanoid";
import { dataUrlToFile } from "@/lib/image-utils";
import { buildImageReferencePromptText } from "@/lib/image-reference-prompt";
@@ -1139,8 +1140,6 @@ export async function fetchImageModels(config: Pick<AiConfig, "baseUrl" | "apiKe
}
}
export type ChannelModelCatalogItem = { id: string; supportedEndpointTypes?: string[] };
export type ChannelModelFetchResult = { models: string[]; catalog: ChannelModelCatalogItem[] };
export async function fetchChannelModels(channel: ModelChannel, viaBackend = false): Promise<ChannelModelFetchResult> {
@@ -1167,8 +1166,8 @@ export async function fetchChannelModels(channel: ModelChannel, viaBackend = fal
}
const catalog = new Map<string, ChannelModelCatalogItem>();
for (const item of response.data.data?.models || []) {
const entry = typeof item === "string" ? { id: item.trim() } : { id: String(item.id || "").trim(), supportedEndpointTypes: Array.isArray(item.supportedEndpointTypes) ? item.supportedEndpointTypes : undefined };
if (!entry.id) continue;
const entry = typeof item === "string" ? sanitizeChannelModelCatalogItem({ id: item }) : sanitizeChannelModelCatalogItem(item);
if (!entry) continue;
const existing = catalog.get(entry.id);
catalog.set(entry.id, existing || entry);
}

View File

@@ -6,7 +6,7 @@ import { getMediaBlob, uploadMediaFile, type UploadedFile } from "@/services/fil
import { getResourceOSSUrl } from "@/services/api/resources";
import { channelRequest } from "@/services/api/custom-channel-relay";
import { imageToDataUrl } from "@/services/image-storage";
import { modelCapabilityConfigFor, videoDurationAllowed } from "@/lib/model-capabilities";
import { modelCapabilityConfigFor, videoDurationAllowed, videoResolutionRequest } from "@/lib/model-capabilities";
import { boolConfig, buildSeedancePromptText, isArkPlanBaseUrl, isSeedanceVideoConfig, normalizeSeedanceDuration, normalizeSeedanceRatio, normalizeSeedanceResolution, seedanceVideoReferenceError, SEEDANCE_REFERENCE_LIMITS } from "@/lib/seedance-video";
import { buildApiUrl, isSystemProxyBaseUrl, modelOptionName, resolveModelRequestConfig, type AiConfig } from "@/stores/use-config-store";
import type { ReferenceImage } from "@/types/image";
@@ -356,7 +356,8 @@ async function createOpenAIVideoTask(config: ResolvedAiConfig, model: string, pr
body.append("prompt", prompt);
body.append("seconds", normalizeVideoSeconds(config.videoSeconds));
if (normalizeVideoSize(config.size)) body.append("size", normalizeVideoSize(config.size)!);
body.append("resolution_name", normalizeVideoResolution(config.vquality));
const resolution = videoResolutionRequest(modelCapabilityConfigFor(config, model).video!, config.vquality);
if (resolution) body.append("resolution_name", resolution);
body.append("preset", "normal");
const files = await Promise.all(references.slice(0, 7).map(async (image) => dataUrlToFile({ ...image, dataUrl: await imageToDataUrl(image) })));
files.forEach((file) => body.append("input_reference[]", file));

View File

@@ -0,0 +1,252 @@
import { afterEach, describe, expect, test } from "bun:test";
import { App } from "antd";
import axios from "axios";
import React from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { CanvasVideoSettingsPopover } from "../src/components/canvas/canvas-video-settings-popover";
import { mergeFetchedChannelModelCosts, type ChannelModelCatalogItem } from "../src/lib/channel-model-catalog";
import { defaultModelCapabilityConfig } from "../src/lib/model-capabilities";
import { ChannelModelSettings } from "../src/pages/settings/channel-video-pricing";
import { fetchChannelModels } from "../src/services/api/image";
import { createVideoGenerationTask } from "../src/services/api/video";
import { createModelChannel, defaultConfig, modelDisplayName, normalizeConfigSnapshot, resolveModelRequestConfig, selectableModelsByCapability, type AiConfig } from "../src/stores/use-config-store";
const originalAxiosPost = axios.post;
afterEach(() => {
axios.post = originalAxiosPost;
});
const omniCatalog: ChannelModelCatalogItem = {
id: "omni",
displayName: "Omni Flash",
modelType: "video",
supportsImages: false,
minImages: 0,
maxImages: 0,
defaultParameters: { aspectRatio: "16:9", durationSeconds: "10" },
options: {
aspectRatio: [{ value: "16:9" }, { value: "9:16" }],
durationSeconds: [{ value: "8" }, { value: "10" }],
},
};
function configForCatalog(catalog: ChannelModelCatalogItem[], input: Partial<AiConfig> = {}) {
const channel = createModelChannel({
id: "flow",
name: "Flow2API",
baseUrl: "https://flow.example",
apiKey: "synthetic-test-key",
apiFormat: "openai",
models: catalog.map((item) => item.id),
});
const configured = { ...channel, modelCosts: mergeFetchedChannelModelCosts(channel, catalog) };
const normalized = normalizeConfigSnapshot({
config: {
...defaultConfig,
...input,
channels: [configured],
model: `flow::${catalog[0]!.id}`,
videoModel: `flow::${catalog[0]!.id}`,
},
}).config;
return input.vquality ? { ...normalized, vquality: input.vquality } : normalized;
}
function formEntries(body: unknown) {
expect(body).toBeInstanceOf(FormData);
return Object.fromEntries(Array.from((body as FormData).entries()).filter((entry): entry is [string, string] => typeof entry[1] === "string"));
}
describe("public channel model catalog", () => {
test("preserves six public capabilities without expanding compatibility IDs", async () => {
axios.post = (async () => ({
data: {
code: 0,
data: {
models: [
{ id: "image-fast", displayName: "Image Fast", modelType: "image" },
{ id: "image-quality", displayName: "Image Quality", modelType: "image" },
{ ...omniCatalog, compatibilityMap: [{ modelId: "omni_portrait_10s" }] },
{ id: "veo-lite", displayName: "Veo Lite", modelType: "video" },
{ id: "veo-fast", displayName: "Veo Fast", modelType: "video" },
{ id: "veo-quality", displayName: "Veo Quality", modelType: "video" },
],
},
},
})) as typeof axios.post;
const channel = createModelChannel({ baseUrl: "https://flow.example", apiKey: "synthetic-test-key", models: [] });
const result = await fetchChannelModels(channel, true);
expect(result.models).toHaveLength(6);
expect(result.models).toContain("omni");
expect(result.models).not.toContain("omni_portrait_10s");
expect(result.catalog.find((item) => item.id === "omni")).toEqual(omniCatalog);
expect(result.catalog.find((item) => item.id === "omni")).not.toHaveProperty("compatibilityMap");
});
test("maps Omni metadata to friendly video/NewAPI configuration with 8/10 seconds and both ratios", () => {
const config = configForCatalog([omniCatalog]);
const model = "flow::omni";
const cost = config.channels[0]!.modelCosts![0]!;
expect(modelDisplayName(config, model)).toBe("Omni Flash");
expect(selectableModelsByCapability(config, "video")).toEqual([model]);
expect(selectableModelsByCapability(config, "text")).toEqual([]);
expect(resolveModelRequestConfig(config, model).interfaceType).toBe("newapi");
expect(cost.capabilityConfig?.video).toMatchObject({
duration: { selection: "enum", values: [8, 10], default: 10 },
ratios: ["16:9", "9:16"],
defaultRatio: "16:9",
resolutions: [],
defaultResolution: "",
});
});
test("preserves a manually configured capability profile when a catalog only returns an ID", () => {
const channel = createModelChannel({
id: "manual",
name: "Manual",
baseUrl: "https://manual.example",
apiKey: "synthetic-test-key",
apiFormat: "openai",
models: ["manual-video"],
});
const capabilityConfig = defaultModelCapabilityConfig("newapi", "manual-video");
capabilityConfig.video!.resolutions = ["1440p"];
capabilityConfig.video!.defaultResolution = "1440p";
channel.modelCosts = [{ model: "manual-video", displayName: "Manual Video", capability: "video", protocol: "newapi", billingMode: "fixed_request", unitPriceMicrocredits: 0, capabilityConfig }];
const costs = mergeFetchedChannelModelCosts(channel, [{ id: "manual-video" }]);
expect(costs[0]?.capabilityConfig).toEqual(capabilityConfig);
expect(costs[0]?.displayName).toBe("Manual Video");
});
test("preserves an ID-only manual model cost when the per-model protocol is missing", () => {
const channel = createModelChannel({
id: "manual-text-channel",
name: "Manual Text Channel",
baseUrl: "https://manual.example",
apiKey: "synthetic-test-key",
apiFormat: "openai",
interfaceType: "chat-completion",
models: ["manual-video"],
});
const capabilityConfig = defaultModelCapabilityConfig("newapi", "manual-video");
capabilityConfig.video!.resolutions = ["1440p"];
capabilityConfig.video!.defaultResolution = "1440p";
const existing = {
model: "manual-video",
displayName: "Manual Video",
capability: "video" as const,
billingMode: "per_second" as const,
unitPriceMicrocredits: 4321,
inputTokenPriceMicrocredits: 11,
outputTokenPriceMicrocredits: 22,
cachedTokenPriceMicrocredits: 3,
capabilityConfig,
};
channel.modelCosts = [existing];
const costs = mergeFetchedChannelModelCosts(channel, [{ id: "manual-video", displayName: "Catalog Video" }]);
expect(costs[0]).toEqual({ ...existing, displayName: "Catalog Video" });
});
test("preserves an ID-only manual video model cost when both protocol sources are missing", () => {
const channel = createModelChannel({
id: "manual-no-protocol",
name: "Manual No Protocol",
baseUrl: "https://manual.example",
apiKey: "synthetic-test-key",
apiFormat: "openai",
models: ["manual-video"],
});
const capabilityConfig = defaultModelCapabilityConfig("newapi", "manual-video");
capabilityConfig.video!.resolutions = ["1440p"];
capabilityConfig.video!.defaultResolution = "1440p";
const existing = {
model: "manual-video",
displayName: "Manual Video",
capability: "video" as const,
billingMode: "per_second" as const,
unitPriceMicrocredits: 8765,
inputTokenPriceMicrocredits: 31,
outputTokenPriceMicrocredits: 47,
cachedTokenPriceMicrocredits: 5,
capabilityConfig,
};
channel.modelCosts = [existing];
const costs = mergeFetchedChannelModelCosts(channel, [{ id: "manual-video", displayName: "Catalog Video" }]);
expect(costs).toEqual([{ ...existing, displayName: "Catalog Video" }]);
});
test("does not show a synthetic 720P label in the Canvas summary when the model declares no resolutions", () => {
const config = configForCatalog([omniCatalog], { videoSeconds: "10", size: "16:9", vquality: "720" });
const html = renderToStaticMarkup(React.createElement(CanvasVideoSettingsPopover, { config, onConfigChange: () => undefined }));
expect(html).toContain("横屏 · 10s");
expect(html).not.toContain("720P");
});
test("shows the public display name in channel model settings instead of only the internal ID", () => {
const config = configForCatalog([omniCatalog]);
const html = renderToStaticMarkup(React.createElement(App, null, React.createElement(ChannelModelSettings, { channel: config.channels[0]!, onChange: () => undefined })));
expect(html).toContain("Omni Flash");
});
test("omits resolution_name for Omni and for auto instead of inventing 720p", async () => {
const bodies: Record<string, string>[] = [];
axios.post = (async (_url: string, body: unknown) => {
bodies.push(formEntries(body));
return { data: { id: `synthetic-${bodies.length}` } };
}) as typeof axios.post;
const omniConfig = configForCatalog([omniCatalog], { videoSeconds: "10", size: "16:9", vquality: "720" });
await createVideoGenerationTask(omniConfig, "synthetic prompt");
const declaredResolution: ChannelModelCatalogItem = {
...omniCatalog,
id: "veo-public",
displayName: "Veo Public",
defaultParameters: { ...omniCatalog.defaultParameters, durationSeconds: "8", resolution: "1080p" },
options: { ...omniCatalog.options, durationSeconds: [{ value: "8" }], resolution: [{ value: "720p" }, { value: "1080p" }] },
};
const autoConfig = configForCatalog([declaredResolution], { videoSeconds: "8", size: "16:9", vquality: "auto" });
await createVideoGenerationTask(autoConfig, "synthetic prompt");
expect(bodies[0]).not.toHaveProperty("resolution_name");
expect(bodies[1]).not.toHaveProperty("resolution_name");
});
test("sends a declared compatible HD resolution for a video capability", async () => {
let body: Record<string, string> = {};
axios.post = (async (_url: string, requestBody: unknown) => {
body = formEntries(requestBody);
return { data: { id: "synthetic-hd" } };
}) as typeof axios.post;
const catalog: ChannelModelCatalogItem = {
...omniCatalog,
id: "veo-public",
displayName: "Veo Public",
defaultParameters: { aspectRatio: "16:9", durationSeconds: "8", resolution: "1080p" },
options: { ...omniCatalog.options, durationSeconds: [{ value: "8" }], resolution: [{ value: "1080p" }] },
};
const config = configForCatalog([catalog], { videoSeconds: "8", size: "16:9", vquality: "1080" });
await createVideoGenerationTask(config, "synthetic prompt");
expect(body.resolution_name).toBe("1080p");
expect(body.model).toBe("veo-public");
expect(body.seconds).toBe("8");
expect(body.size).toBe("1280x720");
});
});