From 6d6ad67dae8a26174e13e68f9d576bcd3b47259d Mon Sep 17 00:00:00 2001 From: Codex Date: Sun, 16 Aug 2026 22:27:29 +0800 Subject: [PATCH] =?UTF-8?q?fix(models):=20=E6=A8=A1=E5=9E=8B=E7=9B=AE?= =?UTF-8?q?=E5=BD=95=20-=20=E8=AF=86=E5=88=AB=E5=85=AC=E5=85=B1=E8=A7=86?= =?UTF-8?q?=E9=A2=91=E8=83=BD=E5=8A=9B=E5=B9=B6=E6=8C=89=E5=A3=B0=E6=98=8E?= =?UTF-8?q?=E5=8F=91=E9=80=81=E5=88=86=E8=BE=A8=E7=8E=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../internal/service/channel_model_catalog.go | 99 ++++++- .../desktop_local_channel_flow_test.go | 61 +++++ backend/internal/service/model_capability.go | 2 +- .../internal/service/model_capability_test.go | 19 ++ backend/internal/service/provider.go | 4 +- backend/internal/service/provider_test.go | 58 ++++ .../service/provider_video_options.go | 37 +++ web/package.json | 2 +- .../canvas/canvas-video-settings-popover.tsx | 8 +- web/src/components/video-settings-panel.tsx | 4 +- web/src/lib/channel-model-catalog.ts | 221 +++++++++++++++ web/src/lib/model-capabilities.ts | 16 ++ web/src/lib/model-protocols.ts | 6 +- web/src/pages/create/index.tsx | 7 +- .../pages/settings/channel-settings-pane.tsx | 30 +-- .../pages/settings/channel-video-pricing.tsx | 5 +- web/src/services/api/image.ts | 7 +- web/src/services/api/video.ts | 5 +- web/test/channel-model-catalog.test.ts | 252 ++++++++++++++++++ 19 files changed, 792 insertions(+), 51 deletions(-) create mode 100644 web/src/lib/channel-model-catalog.ts create mode 100644 web/test/channel-model-catalog.test.ts diff --git a/backend/internal/service/channel_model_catalog.go b/backend/internal/service/channel_model_catalog.go index 6ac9ca5ab..31fad5b39 100644 --- a/backend/internal/service/channel_model_catalog.go +++ b/backend/internal/service/channel_model_catalog.go @@ -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)) diff --git a/backend/internal/service/desktop_local_channel_flow_test.go b/backend/internal/service/desktop_local_channel_flow_test.go index 39468f1f9..b3d5b8f58 100644 --- a/backend/internal/service/desktop_local_channel_flow_test.go +++ b/backend/internal/service/desktop_local_channel_flow_test.go @@ -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 { diff --git a/backend/internal/service/model_capability.go b/backend/internal/service/model_capability.go index f48976315..c90eaed94 100644 --- a/backend/internal/service/model_capability.go +++ b/backend/internal/service/model_capability.go @@ -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") diff --git a/backend/internal/service/model_capability_test.go b/backend/internal/service/model_capability_test.go index 51057c318..7ac821b8f 100644 --- a/backend/internal/service/model_capability_test.go +++ b/backend/internal/service/model_capability_test.go @@ -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) + } +} diff --git a/backend/internal/service/provider.go b/backend/internal/service/provider.go index 11866b29d..cf346e5e4 100644 --- a/backend/internal/service/provider.go +++ b/backend/internal/service/provider.go @@ -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 { diff --git a/backend/internal/service/provider_test.go b/backend/internal/service/provider_test.go index 0ff88ef91..f14e1b872 100644 --- a/backend/internal/service/provider_test.go +++ b/backend/internal/service/provider_test.go @@ -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) diff --git a/backend/internal/service/provider_video_options.go b/backend/internal/service/provider_video_options.go index f19b7e5b0..057cb31dc 100644 --- a/backend/internal/service/provider_video_options.go +++ b/backend/internal/service/provider_video_options.go @@ -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 { diff --git a/web/package.json b/web/package.json index 68de65b85..5485d69c3 100644 --- a/web/package.json +++ b/web/package.json @@ -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 ." diff --git a/web/src/components/canvas/canvas-video-settings-popover.tsx b/web/src/components/canvas/canvas-video-settings-popover.tsx index 77690a38a..e2b11169f 100644 --- a/web/src/components/canvas/canvas-video-settings-popover.tsx +++ b/web/src/components/canvas/canvas-video-settings-popover.tsx @@ -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(null); const [open, setOpen] = useState(false); const [buttonRect, setButtonRect] = useState(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; diff --git a/web/src/components/video-settings-panel.tsx b/web/src/components/video-settings-panel.tsx index 9000931a8..e653afaff 100644 --- a/web/src/components/video-settings-panel.tsx +++ b/web/src/components/video-settings-panel.tsx @@ -50,7 +50,7 @@ export function VideoSettingsPanel({ config, onConfigChange, theme, showTitle =
event.stopPropagation()}> {showTitle ?
视频设置
: null} - + {configuredResolutions.length ?
{configuredResolutions.map((item) => ( onConfigChange("vquality", item.value)}> @@ -58,7 +58,7 @@ export function VideoSettingsPanel({ config, onConfigChange, theme, showTitle = ))}
-
+
: null}
updateDimension("width", value)} /> diff --git a/web/src/lib/channel-model-catalog.ts b/web/src/lib/channel-model-catalog.ts new file mode 100644 index 000000000..49b96bda6 --- /dev/null +++ b/web/src/lib/channel-model-catalog.ts @@ -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[number]; + +export function sanitizeChannelModelCatalogItem(value: unknown): ChannelModelCatalogItem | null { + if (!value || typeof value !== "object") return null; + const record = value as Record; + 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) : 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(); + 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; +} diff --git a/web/src/lib/model-capabilities.ts b/web/src/lib/model-capabilities.ts index 0f5a8390e..dbbe125f8 100644 --- a/web/src/lib/model-capabilities.ts +++ b/web/src/lib/model-capabilities.ts @@ -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; diff --git a/web/src/lib/model-protocols.ts b/web/src/lib/model-protocols.ts index 867082a97..7c53d9edf 100644 --- a/web/src/lib/model-protocols.ts +++ b/web/src/lib/model-protocols.ts @@ -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; } diff --git a/web/src/pages/create/index.tsx b/web/src/pages/create/index.tsx index 5b61ca8c7..4e43be9ac 100644 --- a/web/src/pages/create/index.tsx +++ b/web/src/pages/create/index.tsx @@ -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 =
{props.mode === "video" || props.imageProfile.size.parameter !== "none" ?
{ratios.map((value) => { const selected = props.mode === "image" && usesImageResolutionPicker ? value === activeImageRatio : value === props.ratio; return ; })}
{props.mode !== "video" && props.imageProfile.size.allowCustom && (customRatioOpen ? : )}
: null} - {props.mode === "video" ?
{resolutions.map((option) => )}
: <> + {props.mode === "video" ? (videoResolutionSupported ?
{resolutions.map((option) => )}
: null) : <> {imageResolutionChoiceOptions.length ?
{imageResolutionChoiceOptions.map((choice) => )}
: null} {props.imageProfile.quality.supported ? item.value === "1k" || item.value === "2k") ? "分辨率" : "图片质量"} value={qualityLabel}>
{activeQualityOptions.map((option) => )}
: null} {props.imageProfile.maxOutputs > 1 ?
{countOptions.filter((option) => Number(option) <= props.imageProfile.maxOutputs).map((option) => )}
: 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 ""; } diff --git a/web/src/pages/settings/channel-settings-pane.tsx b/web/src/pages/settings/channel-settings-pane.tsx index 2825cde79..0f8f24cc0 100644 --- a/web/src/pages/settings/channel-settings-pane.tsx +++ b/web/src/pages/settings/channel-settings-pane.tsx @@ -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[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))); } diff --git a/web/src/pages/settings/channel-video-pricing.tsx b/web/src/pages/settings/channel-video-pricing.tsx index 39086fe15..ea835247c 100644 --- a/web/src/pages/settings/channel-video-pricing.tsx +++ b/web/src/pages/settings/channel-video-pricing.tsx @@ -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 (
-
- {model} +
+ {displayName}
diff --git a/web/src/services/api/image.ts b/web/src/services/api/image.ts index df4eaed3c..7c2348198 100644 --- a/web/src/services/api/image.ts +++ b/web/src/services/api/image.ts @@ -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 { @@ -1167,8 +1166,8 @@ export async function fetchChannelModels(channel: ModelChannel, viaBackend = fal } const catalog = new Map(); 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); } diff --git a/web/src/services/api/video.ts b/web/src/services/api/video.ts index e792a8a79..30417c405 100644 --- a/web/src/services/api/video.ts +++ b/web/src/services/api/video.ts @@ -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)); diff --git a/web/test/channel-model-catalog.test.ts b/web/test/channel-model-catalog.test.ts new file mode 100644 index 000000000..a4cd11ac1 --- /dev/null +++ b/web/test/channel-model-catalog.test.ts @@ -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 = {}) { + 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[] = []; + 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 = {}; + 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"); + }); +});