diff --git a/apps/api/internal/httpapi/kling_compat_integration_test.go b/apps/api/internal/httpapi/kling_compat_integration_test.go index 657439b..5801f37 100644 --- a/apps/api/internal/httpapi/kling_compat_integration_test.go +++ b/apps/api/internal/httpapi/kling_compat_integration_test.go @@ -29,6 +29,23 @@ func TestKlingCompatibilitySimulationFlow(t *testing.T) { } defer db.Close() + var upgradedBaseModels int + if err := db.Pool().QueryRow(ctx, ` +SELECT count(*) +FROM base_model_catalog +WHERE provider_key = 'keling' + AND provider_model_name IN ('kling-video-o1', 'kling-v3-omni') + AND model_type @> '["video_generate","image_to_video","omni_video"]'::jsonb + AND capabilities ? 'video_generate' + AND capabilities ? 'image_to_video' + AND capabilities ? 'omni_video' + AND metadata->'rawModel'->'types' @> '["video_generate","image_to_video","omni_video"]'::jsonb`).Scan(&upgradedBaseModels); err != nil { + t.Fatalf("read upgraded Kling Omni base model capabilities: %v", err) + } + if upgradedBaseModels != 2 { + t.Fatalf("expected both Kling Omni base models to expose base video capabilities, got %d", upgradedBaseModels) + } + serverCtx, cancelServer := context.WithCancel(ctx) defer cancelServer() server := httptest.NewServer(NewServerWithContext(serverCtx, config.Config{ @@ -89,6 +106,55 @@ func TestKlingCompatibilitySimulationFlow(t *testing.T) { "displayName": model, }, http.StatusCreated, nil) } + var upgradedPlatformModels int + if err := db.Pool().QueryRow(ctx, ` +SELECT count(*) +FROM platform_models +WHERE platform_id = $1::uuid + AND model_type @> '["video_generate","image_to_video","omni_video"]'::jsonb + AND capabilities ? 'video_generate' + AND capabilities ? 'image_to_video' + AND capabilities ? 'omni_video'`, platform.ID).Scan(&upgradedPlatformModels); err != nil { + t.Fatalf("read upgraded Kling Omni platform model capabilities: %v", err) + } + if upgradedPlatformModels != 2 { + t.Fatalf("expected both Kling Omni platform models to expose base video capabilities, got %d", upgradedPlatformModels) + } + + assertGenericVideoGeneration := func(name string, model string, image string, expectedModelType string) { + t.Helper() + t.Run(name, func(t *testing.T) { + request := map[string]any{ + "model": model, + "prompt": "通用视频接口模拟任务", + "duration": 5, + "resolution": "720p", + "runMode": "simulation", + "simulation": true, + "simulationDurationMs": 5, + } + if image != "" { + request["image"] = image + } + var response struct { + Task struct { + Status string `json:"status"` + ModelType string `json:"modelType"` + ResolvedModel string `json:"resolvedModel"` + } `json:"task"` + } + doJSON(t, server.URL, http.MethodPost, "/api/v1/videos/generations", apiKeyResponse.Secret, request, http.StatusAccepted, &response) + resolvedModel, resolved := klingV2ProviderModel(response.Task.ResolvedModel) + if response.Task.Status != "succeeded" || response.Task.ModelType != expectedModelType || !resolved || resolvedModel != model { + t.Fatalf("generic video request without modelType should use inferred capability: %+v", response.Task) + } + }) + } + + for _, model := range []string{klingO1Model, klingV3OmniModel} { + assertGenericVideoGeneration(model+"-text-to-video", model, "", "video_generate") + assertGenericVideoGeneration(model+"-image-to-video", model, "https://example.com/first.png", "image_to_video") + } createV1 := func(model string, duration int, externalID string) string { t.Helper() diff --git a/apps/api/internal/store/base_models.go b/apps/api/internal/store/base_models.go index fc2ee64..38067c1 100644 --- a/apps/api/internal/store/base_models.go +++ b/apps/api/internal/store/base_models.go @@ -488,6 +488,8 @@ func modelTypeAliases(value string) []string { return []string{"image_edit"} case "video", "videos.generations": return []string{"video_generate"} + case "omni_video": + return []string{"video_generate", "image_to_video", "omni_video"} case "song", "music", "song.generations", "music.generations", "music_generate": return []string{"audio_generate"} case "speech", "speech.generations", "tts": diff --git a/apps/api/internal/store/candidates_test.go b/apps/api/internal/store/candidates_test.go index b5e09fd..b66c857 100644 --- a/apps/api/internal/store/candidates_test.go +++ b/apps/api/internal/store/candidates_test.go @@ -9,6 +9,19 @@ func TestNormalizeModelMatchKeyRemovesWhitespace(t *testing.T) { } } +func TestNormalizeModelTypeListExpandsOmniVideoBaseCapabilities(t *testing.T) { + got := normalizeModelTypeList([]string{"omni_video"}) + want := StringList{"video_generate", "image_to_video", "omni_video"} + if len(got) != len(want) { + t.Fatalf("omni_video should include text-to-video and image-to-video capabilities: got=%v want=%v", got, want) + } + for index := range want { + if got[index] != want[index] { + t.Fatalf("omni_video capability mismatch at %d: got=%v want=%v", index, got, want) + } + } +} + func TestTaskBillingModelIdentityKeepsRequestedModelPrimary(t *testing.T) { identity := taskBillingModelIdentity(GatewayTask{ Model: "doubao-5.0 图像编辑", diff --git a/apps/api/migrations/0074_keling_omni_base_video_capabilities.sql b/apps/api/migrations/0074_keling_omni_base_video_capabilities.sql new file mode 100644 index 0000000..d13d95f --- /dev/null +++ b/apps/api/migrations/0074_keling_omni_base_video_capabilities.sql @@ -0,0 +1,182 @@ +WITH keling_omni_models(provider_model_name, model_type, capabilities) AS ( + VALUES + ( + 'kling-video-o1', + '["video_generate","image_to_video","omni_video"]'::jsonb, + '{ + "video_generate": { + "output_resolutions": ["720p", "1080p"], + "aspect_ratio_allowed": ["16:9", "1:1", "9:16"], + "duration_options": [3, 4, 5, 6, 7, 8, 9, 10], + "output_audio": false, + "input_audio": false, + "prompt_length_limit": { + "max": 2500, + "count_mode": "non_ascii_weighted", + "label": "可灵口径" + } + }, + "image_to_video": { + "output_resolutions": ["720p", "1080p"], + "aspect_ratio_allowed": ["16:9", "1:1", "9:16"], + "duration_options": [3, 4, 5, 6, 7, 8, 9, 10], + "input_first_frame": true, + "input_last_frame": false, + "input_first_last_frame": true, + "input_reference_generate_single": true, + "input_reference_generate_multiple": true, + "max_images": 7, + "max_images_for_last_frame": 2, + "support_video_effect_template": false, + "output_audio": false, + "input_audio": false, + "prompt_length_limit": { + "max": 2500, + "count_mode": "non_ascii_weighted", + "label": "可灵口径" + } + }, + "omni_video": { + "supported_modes": ["text_to_video", "image_reference", "element_reference", "first_last_frame", "video_reference", "video_edit"], + "output_resolutions": ["720p", "1080p"], + "aspect_ratio_allowed": ["16:9", "1:1", "9:16"], + "duration_options": [3, 4, 5, 6, 7, 8, 9, 10], + "output_audio": false, + "input_audio": false, + "max_videos": 1, + "max_audios": 0, + "max_images": 7, + "max_elements": 7, + "max_images_and_elements": 7, + "limits_with_video": {"max_images_and_elements": 4}, + "max_images_for_last_frame": 2, + "support_instruction_edit": true, + "prompt_length_limit": { + "max": 2500, + "count_mode": "non_ascii_weighted", + "label": "可灵口径" + } + }, + "originalTypes": ["video_generate", "image_to_video", "omni_video"] + }'::jsonb + ), + ( + 'kling-v3-omni', + '["video_generate","image_to_video","omni_video"]'::jsonb, + '{ + "video_generate": { + "output_resolutions": ["720p", "1080p", "2160p"], + "aspect_ratio_allowed": ["16:9", "1:1", "9:16"], + "duration_options": [3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15], + "output_audio": true, + "input_audio": false, + "prompt_length_limit": { + "max": 2500, + "count_mode": "non_ascii_weighted", + "label": "可灵口径" + } + }, + "image_to_video": { + "output_resolutions": ["720p", "1080p", "2160p"], + "aspect_ratio_allowed": ["16:9", "1:1", "9:16"], + "duration_options": [3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15], + "input_first_frame": true, + "input_last_frame": false, + "input_first_last_frame": true, + "input_reference_generate_single": true, + "input_reference_generate_multiple": true, + "max_images": 7, + "max_images_for_last_frame": 2, + "support_video_effect_template": false, + "output_audio": true, + "input_audio": false, + "prompt_length_limit": { + "max": 2500, + "count_mode": "non_ascii_weighted", + "label": "可灵口径" + } + }, + "omni_video": { + "supported_modes": ["text_to_video", "image_reference", "element_reference", "first_last_frame", "video_reference", "video_edit", "multi_shot"], + "output_resolutions": ["720p", "1080p", "2160p"], + "aspect_ratio_allowed": ["16:9", "1:1", "9:16"], + "duration_options": [3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15], + "output_audio": true, + "input_audio": false, + "max_videos": 1, + "max_audios": 0, + "max_images": 7, + "max_elements": 7, + "max_images_and_elements": 7, + "limits_with_video": { + "max_images_and_elements": 4, + "duration_options": [3, 4, 5, 6, 7, 8, 9, 10] + }, + "max_images_for_last_frame": 2, + "support_instruction_edit": true, + "prompt_length_limit": { + "max": 2500, + "count_mode": "non_ascii_weighted", + "label": "可灵口径" + } + }, + "originalTypes": ["video_generate", "image_to_video", "omni_video"] + }'::jsonb + ) +) +UPDATE base_model_catalog model +SET model_type = defs.model_type, + capabilities = defs.capabilities, + metadata = COALESCE(model.metadata, '{}'::jsonb) + || jsonb_build_object( + 'originalTypes', defs.model_type, + 'rawModel', COALESCE(model.metadata->'rawModel', '{}'::jsonb) + || jsonb_build_object( + 'types', defs.model_type, + 'capabilities', defs.capabilities + ) + ), + default_snapshot = CASE + WHEN COALESCE(model.default_snapshot, '{}'::jsonb) = '{}'::jsonb THEN model.default_snapshot + ELSE jsonb_set( + jsonb_set( + jsonb_set(model.default_snapshot, '{modelType}', defs.model_type, true), + '{capabilities}', + defs.capabilities, + true + ), + '{metadata}', + COALESCE(model.default_snapshot->'metadata', '{}'::jsonb) + || jsonb_build_object( + 'originalTypes', defs.model_type, + 'rawModel', COALESCE(model.default_snapshot->'metadata'->'rawModel', '{}'::jsonb) + || jsonb_build_object( + 'types', defs.model_type, + 'capabilities', defs.capabilities + ) + ), + true + ) + END, + updated_at = now() +FROM keling_omni_models defs +WHERE model.provider_model_name = defs.provider_model_name + AND model.model_type @> '["omni_video"]'::jsonb; + +WITH keling_omni_models(provider_model_name, model_type, capabilities) AS ( + SELECT DISTINCT ON (model.provider_model_name) + model.provider_model_name, + model.model_type, + model.capabilities + FROM base_model_catalog model + WHERE model.provider_model_name IN ('kling-video-o1', 'kling-v3-omni') + AND model.model_type @> '["omni_video"]'::jsonb + ORDER BY model.provider_model_name, (model.provider_key = 'keling') DESC, model.created_at ASC +) +UPDATE platform_models model +SET model_type = defs.model_type, + capabilities = defs.capabilities, + updated_at = now() +FROM keling_omni_models defs +WHERE COALESCE(NULLIF(model.provider_model_name, ''), model.model_name) = defs.provider_model_name + AND model.model_type @> '["omni_video"]'::jsonb;