diff --git a/common/endpoint_type.go b/common/endpoint_type.go index e0feca5a409..33ba044c55e 100644 --- a/common/endpoint_type.go +++ b/common/endpoint_type.go @@ -40,6 +40,8 @@ func GetEndpointTypesByChannelType(channelType int, modelName string) []constant fallthrough case constant.ChannelTypeXaiGrokVideo: fallthrough + case constant.ChannelTypeModelAPISeedance: + fallthrough case constant.ChannelTypeMiniMaxH3: endpointTypes = []constant.EndpointType{constant.EndpointTypeOpenAIVideo} case constant.ChannelTypeSonilo: diff --git a/common/endpoint_type_test.go b/common/endpoint_type_test.go index 3b3b55d6f20..578ab1b6c60 100644 --- a/common/endpoint_type_test.go +++ b/common/endpoint_type_test.go @@ -55,3 +55,10 @@ func TestGetEndpointTypesByChannelType_Sonilo(t *testing.T) { t.Fatalf("expected endpoints to contain %q, got %v", constant.EndpointTypeVideoToMusic, got) } } + +func TestGetEndpointTypesByChannelType_ModelAPISeedance(t *testing.T) { + got := GetEndpointTypesByChannelType(constant.ChannelTypeModelAPISeedance, "doubao-seedance-2-5-260628") + if !containsEndpointType(got, constant.EndpointTypeOpenAIVideo) { + t.Fatalf("expected endpoints to contain %q, got %v", constant.EndpointTypeOpenAIVideo, got) + } +} diff --git a/constant/channel.go b/constant/channel.go index 4594565e01b..2be7cbb2ebe 100644 --- a/constant/channel.go +++ b/constant/channel.go @@ -70,8 +70,8 @@ const ( ChannelTypeXaiGrokVideo = 108 // xAI Grok Imagine async video API (submit → poll); whitelabel ChannelTypeSonilo = 109 // Sonilo async video-to-music API; whitelabel ChannelTypeMiniMaxH3 = 110 // MiniMax H3 async video API - ChannelTypeDummy // this one is only for count, do not add any channel after this - + ChannelTypeModelAPISeedance = 111 // ModelAPI Seedance 2.5 async video API; whitelabel + ChannelTypeDummy = 112 // this one is only for count, do not add any channel after this ) var ChannelBaseURLs = []string{ @@ -151,6 +151,7 @@ var ChannelBaseURLs = []string{ "https://api.x.ai", // 108 XaiGrokVideo "https://api.sonilo.com", // 109 Sonilo "https://api.minimax.io", // 110 MiniMaxH3 + "https://api.modelapi.co", // 111 ModelAPISeedance } var ChannelTypeNames = map[int]string{ @@ -220,6 +221,7 @@ var ChannelTypeNames = map[int]string{ ChannelTypeXaiGrokVideo: "XaiGrokVideo", ChannelTypeSonilo: "Sonilo", ChannelTypeMiniMaxH3: "MiniMaxH3", + ChannelTypeModelAPISeedance: "ModelAPISeedance", } func GetChannelTypeName(channelType int) string { diff --git a/constant/modelapi_seedance_channel_test.go b/constant/modelapi_seedance_channel_test.go new file mode 100644 index 00000000000..905f19ae1c9 --- /dev/null +++ b/constant/modelapi_seedance_channel_test.go @@ -0,0 +1,21 @@ +package constant + +import "testing" + +func TestModelAPISeedanceChannelRegistration(t *testing.T) { + if ChannelTypeModelAPISeedance != 111 { + t.Fatalf("ChannelTypeModelAPISeedance = %d, want 111", ChannelTypeModelAPISeedance) + } + if ChannelTypeDummy <= ChannelTypeModelAPISeedance { + t.Fatalf("ChannelTypeDummy = %d, want after ModelAPISeedance %d", ChannelTypeDummy, ChannelTypeModelAPISeedance) + } + if len(ChannelBaseURLs) <= ChannelTypeModelAPISeedance { + t.Fatalf("ChannelBaseURLs length = %d, want index %d", len(ChannelBaseURLs), ChannelTypeModelAPISeedance) + } + if got := ChannelBaseURLs[ChannelTypeModelAPISeedance]; got != "https://api.modelapi.co" { + t.Fatalf("ChannelBaseURLs[ChannelTypeModelAPISeedance] = %q, want %q", got, "https://api.modelapi.co") + } + if got := GetChannelTypeName(ChannelTypeModelAPISeedance); got != "ModelAPISeedance" { + t.Fatalf("GetChannelTypeName(ChannelTypeModelAPISeedance) = %q, want %q", got, "ModelAPISeedance") + } +} diff --git a/controller/asset_task_worker.go b/controller/asset_task_worker.go index 79d4cb238cb..aa9987244a0 100644 --- a/controller/asset_task_worker.go +++ b/controller/asset_task_worker.go @@ -729,10 +729,15 @@ func quarantineLeasedAssetTaskSubmissionUnknown(task *model.Task, lease *taskPre } func taskPollingKey(channel *model.Channel, info *relaycommon.RelayInfo) string { - if channel == nil || channel.Type != constant.ChannelTypeTechMobiVideo || info == nil || info.ChannelMeta == nil { + if channel == nil || info == nil || info.ChannelMeta == nil { + return "" + } + switch channel.Type { + case constant.ChannelTypeTechMobiVideo, constant.ChannelTypeModelAPISeedance: + return strings.TrimSpace(info.ChannelMeta.ApiKey) + default: return "" } - return strings.TrimSpace(info.ChannelMeta.ApiKey) } func acceptLeasedAssetTask(c *gin.Context, info *relaycommon.RelayInfo, task *model.Task, owner string, lease *taskPreparationLease, channel *model.Channel, result *relay.TaskSubmitResult) error { diff --git a/controller/asset_task_worker_test.go b/controller/asset_task_worker_test.go index 9c86beada71..30945517e55 100644 --- a/controller/asset_task_worker_test.go +++ b/controller/asset_task_worker_test.go @@ -8,6 +8,7 @@ import ( "io" "net/http" "net/http/httptest" + "strconv" "strings" "sync/atomic" "testing" @@ -240,6 +241,133 @@ func TestNonAssetRelayTaskSubmitsSynchronously(t *testing.T) { require.Equal(t, "upstream-sync", submitted.PrivateData.UpstreamTaskID) } +func TestModelAPISeedanceSubmitEstimatedUSDSettlesAndPersistsAdjustedBilling(t *testing.T) { + const ( + userID = 57 + tokenID = 58 + channelID = 159 + subID = 60 + planID = 61 + modelPrice = 0.14 + estimatedUSD = 1.25 + ) + expectedUnits := estimatedUSD / modelPrice + // Shared fixed-price task billing recalculates from integer quota units and + // intentionally preserves truncation at the submit-time adjustment boundary. + expectedQuota := 624999 + + tests := []struct { + name string + userQuota int + userSetting dto.UserSetting + seedSub bool + wantSource string + wantUserQuota int + wantSubUsed int64 + }{ + { + name: "wallet", + userQuota: 2000000, + userSetting: dto.UserSetting{BillingPreference: "wallet_only"}, + wantSource: service.BillingSourceWallet, + wantUserQuota: 2000000 - expectedQuota, + }, + { + name: "subscription", + userQuota: 0, + userSetting: dto.UserSetting{}, + seedSub: true, + wantSource: service.BillingSourceSubscription, + wantUserQuota: 0, + wantSubUsed: int64(expectedQuota), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + restoreDB := useControllerAssetTaskDBForTest(t) + defer restoreDB() + restorePricing := useControllerAssetTaskPricingForTest(t) + defer restorePricing() + require.NoError(t, ratio_setting.UpdateModelPriceByJSONString(`{"seedance-2.0":0.14}`)) + service.InitHttpClient() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + require.Equal(t, "/v1/tasks", r.URL.Path) + require.Equal(t, "Bearer sk-modelapi-provider", r.Header.Get("Authorization")) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"task_id":"upstream-modelapi-` + tt.name + `","status":"pending","usage":{"estimated_usd":1.25}}`)) + })) + defer server.Close() + + seedControllerRelayUserToken(t, userID, tokenID, tt.userQuota, 2000000) + seedControllerModelAPISeedanceChannel(t, channelID, server.URL) + if tt.seedSub { + require.NoError(t, model.DB.Create(&model.SubscriptionPlan{ + Id: planID, + Title: "ModelAPI Seedance submit billing plan", + DurationUnit: "month", + DurationValue: 1, + TotalAmount: 2000000, + Window5hAmount: 2000000, + WindowWeekAmount: 2000000, + }).Error) + require.NoError(t, model.DB.Create(&model.UserSubscription{ + Id: subID, + UserId: userID, + PlanId: planID, + AmountTotal: 2000000, + AmountUsed: 0, + Status: "active", + StartTime: time.Now().Add(-time.Hour).Unix(), + EndTime: time.Now().Add(time.Hour).Unix(), + }).Error) + } + model.InitChannelCache() + + c, recorder := newControllerRelayTaskContext(`{"model":"seedance-2.0","content":[{"type":"text","text":"cinematic tea ad"}]}`) + common.SetContextKey(c, constant.ContextKeyUserId, userID) + common.SetContextKey(c, constant.ContextKeyUserGroup, "default") + common.SetContextKey(c, constant.ContextKeyTokenId, tokenID) + common.SetContextKey(c, constant.ContextKeyTokenKey, "sk-task-token-58") + common.SetContextKey(c, constant.ContextKeyTokenGroup, "default") + common.SetContextKey(c, constant.ContextKeyUsingGroup, "default") + common.SetContextKey(c, constant.ContextKeyUserSetting, tt.userSetting) + common.SetContextKey(c, constant.ContextKeyOriginalModel, "seedance-2.0") + common.SetContextKey(c, constant.ContextKeyChannelId, channelID) + common.SetContextKey(c, constant.ContextKeyChannelType, constant.ChannelTypeModelAPISeedance) + common.SetContextKey(c, constant.ContextKeyChannelBaseUrl, server.URL) + common.SetContextKey(c, constant.ContextKeyChannelKey, "sk-modelapi-provider") + c.Set("platform", strconv.Itoa(constant.ChannelTypeModelAPISeedance)) + c.Set("token_name", "task-token") + c.Set("token_quota", 2000000) + + RelayTask(c) + + require.Equal(t, http.StatusOK, recorder.Code, recorder.Body.String()) + var response dto.OpenAIVideo + require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &response)) + require.NotEmpty(t, response.TaskID) + + var task model.Task + require.NoError(t, model.DB.Where("task_id = ?", response.TaskID).First(&task).Error) + require.Equal(t, "upstream-modelapi-"+tt.name, task.PrivateData.UpstreamTaskID) + require.Equal(t, expectedQuota, task.Quota) + require.Equal(t, tt.wantSource, task.PrivateData.BillingSource) + require.NotNil(t, task.PrivateData.BillingContext) + require.True(t, task.PrivateData.BillingContext.PerCallBilling) + require.InDelta(t, expectedUnits, task.PrivateData.BillingContext.OtherRatios["billable_units"], 1e-9) + + require.Equal(t, tt.wantUserQuota, getControllerUserQuota(t, userID)) + require.Equal(t, 2000000-expectedQuota, getControllerTokenRemain(t, tokenID)) + if tt.seedSub { + require.Equal(t, tt.wantSubUsed, getControllerSubscriptionUsed(t, subID)) + } + }) + } +} + func TestAssetTaskWorkerFallbackBeforeAcceptancePinsWinningChannel(t *testing.T) { restoreDB := useControllerAssetTaskDBForTest(t) defer restoreDB() @@ -346,6 +474,52 @@ func TestTechMobiAssetTaskWorkerPersistsSelectedKeyAfterAcceptance(t *testing.T) require.Equal(t, "techmobi-key-b", stored.PrivateData.Key) } +func TestModelAPISeedanceAssetTaskWorkerPersistsSelectedKeyAfterAcceptance(t *testing.T) { + restoreDB := useControllerAssetTaskDBForTest(t) + defer restoreDB() + restorePricing := useControllerAssetTaskPricingForTest(t) + defer restorePricing() + restoreHooks := useAssetTaskWorkerHooksForTest(t, 100, func() int64 { return assetTaskWorkerTestNow }) + defer restoreHooks() + oldRetryTimes := common.RetryTimes + common.RetryTimes = 0 + defer func() { common.RetryTimes = oldRetryTimes }() + + adaptor := &controllerFakeTaskAdaptor{upstreamTaskID: "modelapi-upstream-task"} + restoreAdaptor := registerTaskAdaptorForTest(constant.TaskPlatform(fmt.Sprint(constant.ChannelTypeModelAPISeedance)), adaptor) + defer restoreAdaptor() + + publicID := "ast_8234567890abcdefABCDEF1234567890" + seedControllerRelayUserToken(t, 7, 11, 10000, 10000) + seedControllerModelAPISeedanceMultiKeyChannel(t, 49) + seedControllerAsset(t, 7, publicID, time.Now().Add(time.Hour).Unix()) + var asset model.Asset + require.NoError(t, model.DB.Where("public_id = ?", publicID).First(&asset).Error) + require.NoError(t, model.DB.Create(&model.AssetBinding{ + AssetId: asset.Id, + ChannelId: 49, + BindingScope: "", + Status: model.AssetStatusActive, + UpstreamAssetId: "modelapi-bound-" + publicID, + }).Error) + task := seedControllerQueuedAssetTask(t, "task_modelapi_selected_key", model.TaskPreparationStatusPreparingAssets, "", 0) + task.ChannelId = 0 + task.NormalizedRequestPayload = []byte(seedanceTaskBody(publicID)) + require.NoError(t, model.DB.Save(task).Error) + model.InitChannelCache() + + assetTaskWorkerTestNow = 1000 + processed, err := RunAssetTaskWorkerOnce(context.Background(), "node-a", 10) + require.NoError(t, err) + require.Equal(t, 1, processed) + + var stored model.Task + require.NoError(t, model.DB.Where("task_id = ?", task.TaskID).First(&stored).Error) + require.EqualValues(t, model.TaskStatusSubmitted, stored.Status) + require.Equal(t, 49, stored.ChannelId) + require.Equal(t, "modelapi-key-b", stored.PrivateData.Key) +} + func TestTechMobiAssetTaskWorkerRequeuesProcessingBindingThenSubmitsWhenActive(t *testing.T) { restoreDB := useControllerAssetTaskDBForTest(t) defer restoreDB() @@ -552,6 +726,52 @@ func TestTechMobiAssetTaskWorkerPersistsSelectedKeyForUnknownSubmission(t *testi require.Equal(t, "techmobi-key-b", stored.PrivateData.Key) } +func TestModelAPISeedanceAssetTaskWorkerPersistsSelectedKeyForUnknownSubmission(t *testing.T) { + restoreDB := useControllerAssetTaskDBForTest(t) + defer restoreDB() + restorePricing := useControllerAssetTaskPricingForTest(t) + defer restorePricing() + restoreHooks := useAssetTaskWorkerHooksForTest(t, 100, func() int64 { return assetTaskWorkerTestNow }) + defer restoreHooks() + oldRetryTimes := common.RetryTimes + common.RetryTimes = 0 + defer func() { common.RetryTimes = oldRetryTimes }() + + adaptor := &controllerFakeTaskAdaptor{failByChannel: map[int]error{49: assertErr("connection reset after request write")}} + restoreAdaptor := registerTaskAdaptorForTest(constant.TaskPlatform(fmt.Sprint(constant.ChannelTypeModelAPISeedance)), adaptor) + defer restoreAdaptor() + + publicID := "ast_9334567890abcdefABCDEF1234567890" + seedControllerRelayUserToken(t, 7, 11, 10000, 10000) + seedControllerModelAPISeedanceMultiKeyChannel(t, 49) + seedControllerAsset(t, 7, publicID, time.Now().Add(time.Hour).Unix()) + var asset model.Asset + require.NoError(t, model.DB.Where("public_id = ?", publicID).First(&asset).Error) + require.NoError(t, model.DB.Create(&model.AssetBinding{ + AssetId: asset.Id, + ChannelId: 49, + BindingScope: "", + Status: model.AssetStatusActive, + UpstreamAssetId: "modelapi-bound-" + publicID, + }).Error) + task := seedControllerQueuedAssetTask(t, "task_modelapi_unknown_key", model.TaskPreparationStatusPreparingAssets, "", 0) + task.ChannelId = 0 + task.NormalizedRequestPayload = []byte(seedanceTaskBody(publicID)) + require.NoError(t, model.DB.Save(task).Error) + model.InitChannelCache() + + assetTaskWorkerTestNow = 1000 + processed, err := RunAssetTaskWorkerOnce(context.Background(), "node-a", 10) + require.NoError(t, err) + require.Equal(t, 1, processed) + + var stored model.Task + require.NoError(t, model.DB.Where("task_id = ?", task.TaskID).First(&stored).Error) + require.EqualValues(t, model.TaskStatusUnknown, stored.Status) + require.Equal(t, model.TaskPreparationStatusUnknownOutcome, stored.PreparationStatus) + require.Equal(t, "modelapi-key-b", stored.PrivateData.Key) +} + func TestAssetTaskWorkerCrossTypeFallbackUsesSelectedAdaptorAndPricing(t *testing.T) { restoreDB := useControllerAssetTaskDBForTest(t) defer restoreDB() @@ -2470,6 +2690,47 @@ func seedControllerTaskChannelTypeWithPriority(t *testing.T, id int, channelType }).Error) } +func seedControllerModelAPISeedanceChannel(t *testing.T, id int, baseURL string) { + t.Helper() + priority := int64(100) + weight := uint(1) + require.NoError(t, model.DB.Create(&model.Channel{ + Id: id, + Type: constant.ChannelTypeModelAPISeedance, + Key: "sk-modelapi-provider", + Status: common.ChannelStatusEnabled, + Name: fmt.Sprintf("modelapi-seedance-%d", id), + Group: "default", + Models: "seedance-2.0", + BaseURL: common.GetPointer(baseURL), + Priority: &priority, + Weight: &weight, + }).Error) + require.NoError(t, model.DB.Create(&model.Ability{ + Group: "default", + Model: "seedance-2.0", + ChannelId: id, + Enabled: true, + Priority: &priority, + Weight: weight, + }).Error) +} + +func seedControllerModelAPISeedanceMultiKeyChannel(t *testing.T, id int) { + t.Helper() + seedControllerTaskChannelTypeWithPriority(t, id, constant.ChannelTypeModelAPISeedance, "modelapi-key-a\nmodelapi-key-b", 100, 1) + channelInfo := model.ChannelInfo{ + IsMultiKey: true, + MultiKeySize: 2, + MultiKeyMode: constant.MultiKeyModePolling, + MultiKeyPollingIndex: 1, + MultiKeyStatusList: map[int]int{ + 0: common.ChannelStatusManuallyDisabled, + }, + } + require.NoError(t, model.DB.Model(&model.Channel{}).Where("id = ?", id).Update("channel_info", channelInfo).Error) +} + func seedControllerTechMobiTaskChannel(t *testing.T, id int) { t.Helper() seedControllerTaskChannelTypeWithPriority(t, id, constant.ChannelTypeTechMobiVideo, "techmobi-key-a\ntechmobi-key-b", 100, 1) diff --git a/controller/channel.go b/controller/channel.go index 63c72c3622f..55b623d1ad0 100644 --- a/controller/channel.go +++ b/controller/channel.go @@ -467,6 +467,9 @@ func validateChannel(channel *model.Channel, isAdd bool) error { if err := channel.ValidateSettings(); err != nil { return fmt.Errorf("渠道额外设置[channel setting] 格式错误:%s", err.Error()) } + if channel.Type == constant.ChannelTypeModelAPISeedance && strings.TrimSpace(channel.GetSetting().Proxy) != "" { + return fmt.Errorf("this channel type does not support proxy") + } // 如果是添加操作,检查 channel 和 key 是否为空 if isAdd { diff --git a/controller/channel_concurrency_validation_test.go b/controller/channel_concurrency_validation_test.go index 7b09ba987e5..9b3e467cc3b 100644 --- a/controller/channel_concurrency_validation_test.go +++ b/controller/channel_concurrency_validation_test.go @@ -3,6 +3,7 @@ package controller import ( "testing" + "github.com/QuantumNous/new-api/constant" "github.com/QuantumNous/new-api/model" "github.com/stretchr/testify/require" ) @@ -13,3 +14,19 @@ func TestValidateChannelRejectsInvalidMaxConcurrency(t *testing.T) { err := validateChannel(&model.Channel{MaxConcurrency: -1}, true) require.ErrorContains(t, err, "channel max concurrency cannot be negative") } + +func TestValidateChannelRejectsModelAPISeedanceProxy(t *testing.T) { + setting := `{"proxy":" socks5://127.0.0.1:1080 "}` + channel := &model.Channel{ + Type: constant.ChannelTypeModelAPISeedance, + Key: "sk-test", + Models: "doubao-seedance-2-5-260628", + Setting: &setting, + } + + err := validateChannel(channel, true) + require.ErrorContains(t, err, "this channel type does not support proxy") + require.NotContains(t, err.Error(), "ModelAPI") + require.NotContains(t, err.Error(), "modelapi") + require.NotContains(t, err.Error(), "api.modelapi.co") +} diff --git a/controller/video_proxy.go b/controller/video_proxy.go index 430b4ed7a26..14fe2dfdfa9 100644 --- a/controller/video_proxy.go +++ b/controller/video_proxy.go @@ -88,7 +88,12 @@ func VideoProxy(c *gin.Context) { baseURL = "https://api.openai.com" } - if tryRedirectArchivedTechMobiVideo(c, task, channel) { + if tryRedirectArchivedVideoResult(c, task, channel) { + return + } + if channel.Type == constant.ChannelTypeModelAPISeedance { + perfmetrics.RecordVideoResultRedirect("modelapi", "unavailable") + videoProxyError(c, http.StatusBadGateway, "server_error", "video result is unavailable") return } @@ -226,13 +231,21 @@ func VideoProxy(c *gin.Context) { } func tryRedirectArchivedTechMobiVideo(c *gin.Context, task *model.Task, channel *model.Channel) bool { - if c == nil || task == nil || channel == nil || channel.Type != constant.ChannelTypeTechMobiVideo || task.PrivateData.VideoResult == nil { + return tryRedirectArchivedVideoResult(c, task, channel) +} + +func tryRedirectArchivedVideoResult(c *gin.Context, task *model.Task, channel *model.Channel) bool { + if c == nil || task == nil || channel == nil || task.PrivateData.VideoResult == nil { + return false + } + channelLabel := service.VideoResultChannelLabel(channel.Type) + if channelLabel == "" { return false } signedURL, err := signArchivedVideoResultDownload(c.Request.Context(), c.Param("task_id"), task.PrivateData.VideoResult) if err == nil { - perfmetrics.RecordVideoResultRedirect("techmobi", "success") + perfmetrics.RecordVideoResultRedirect(channelLabel, "success") c.Writer.Header().Set("Location", signedURL) c.Writer.Header().Set("Cache-Control", "no-store") c.Writer.Header().Set("Pragma", "no-cache") @@ -243,13 +256,13 @@ func tryRedirectArchivedTechMobiVideo(c *gin.Context, task *model.Task, channel switch { case errors.Is(err, service.ErrVideoResultExpired): - perfmetrics.RecordVideoResultRedirect("techmobi", "expired") + perfmetrics.RecordVideoResultRedirect(channelLabel, "expired") videoProxyError(c, http.StatusGone, "invalid_request_error", "video result has expired") case errors.Is(err, service.ErrVideoResultUnavailable): - perfmetrics.RecordVideoResultRedirect("techmobi", "unavailable") + perfmetrics.RecordVideoResultRedirect(channelLabel, "unavailable") videoProxyError(c, http.StatusBadGateway, "server_error", "video result is unavailable") default: - perfmetrics.RecordVideoResultRedirect("techmobi", "signing-or-other") + perfmetrics.RecordVideoResultRedirect(channelLabel, "signing-or-other") videoProxyError(c, http.StatusServiceUnavailable, "server_error", "video result is temporarily unavailable") } return true diff --git a/controller/video_proxy_video_result_test.go b/controller/video_proxy_video_result_test.go index 830f35fded0..a6d9d5fd0bc 100644 --- a/controller/video_proxy_video_result_test.go +++ b/controller/video_proxy_video_result_test.go @@ -8,13 +8,16 @@ import ( "testing" "time" + "github.com/QuantumNous/new-api/common" "github.com/QuantumNous/new-api/constant" "github.com/QuantumNous/new-api/model" perfmetrics "github.com/QuantumNous/new-api/pkg/perf_metrics" tasktechmobi "github.com/QuantumNous/new-api/relay/channel/task/techmobi" "github.com/QuantumNous/new-api/service" "github.com/gin-gonic/gin" + "github.com/glebarez/sqlite" "github.com/stretchr/testify/require" + "gorm.io/gorm" ) func TestArchivedTechMobiVideoRedirect(t *testing.T) { @@ -124,6 +127,123 @@ func TestArchivedTechMobiVideoRedirect(t *testing.T) { }) } +func TestArchivedModelAPIVideoRedirect(t *testing.T) { + t.Run("success returns private redirect without cache", func(t *testing.T) { + resetVideoResultMetricsForControllerTest(t) + recorder, c := newArchivedVideoProxyContext("task_modelapi") + channel := &model.Channel{Type: constant.ChannelTypeModelAPISeedance} + task := archivedModelAPITask(time.Now().Add(time.Hour).Unix()) + installArchivedVideoResultSigner(t, func(_ context.Context, taskID string, result *model.VideoResult) (string, error) { + require.Equal(t, "task_modelapi", taskID) + require.Same(t, task.PrivateData.VideoResult, result) + return "https://signed.example/download?X-Goog-Signature=secret", nil + }) + + require.True(t, tryRedirectArchivedVideoResult(c, task, channel)) + require.Equal(t, http.StatusFound, recorder.Code) + require.Equal(t, "https://signed.example/download?X-Goog-Signature=secret", recorder.Header().Get("Location")) + require.Equal(t, "no-store", recorder.Header().Get("Cache-Control")) + require.Equal(t, "no-cache", recorder.Header().Get("Pragma")) + require.Empty(t, recorder.Body.String()) + text, err := perfmetrics.BuildPrometheusText(context.Background()) + require.NoError(t, err) + require.Contains(t, text, `newapi_video_result_redirect_total{channel="modelapi",outcome="success"} 1`) + }) + + errorCases := []struct { + name string + signErr error + wantStatus int + wantBody string + wantMetric string + secretSnippet string + }{ + { + name: "expired returns 410 sanitized error", + signErr: service.ErrVideoResultExpired, + wantStatus: http.StatusGone, + wantBody: "video result has expired", + wantMetric: `newapi_video_result_redirect_total{channel="modelapi",outcome="expired"} 1`, + }, + { + name: "unavailable returns 502 sanitized error", + signErr: service.ErrVideoResultUnavailable, + wantStatus: http.StatusBadGateway, + wantBody: "video result is unavailable", + wantMetric: `newapi_video_result_redirect_total{channel="modelapi",outcome="unavailable"} 1`, + }, + { + name: "signing returns 503 sanitized error", + signErr: errors.New("secret https://storage.googleapis.com/video-bucket/object?X-Goog-Signature=abc"), + wantStatus: http.StatusServiceUnavailable, + wantBody: "video result is temporarily unavailable", + wantMetric: `newapi_video_result_redirect_total{channel="modelapi",outcome="signing-or-other"} 1`, + secretSnippet: "storage.googleapis.com", + }, + } + for _, tc := range errorCases { + t.Run(tc.name, func(t *testing.T) { + resetVideoResultMetricsForControllerTest(t) + recorder, c := newArchivedVideoProxyContext("task_modelapi") + channel := &model.Channel{Type: constant.ChannelTypeModelAPISeedance} + installArchivedVideoResultSigner(t, func(context.Context, string, *model.VideoResult) (string, error) { + return "", tc.signErr + }) + + require.True(t, tryRedirectArchivedVideoResult(c, archivedModelAPITask(time.Now().Add(time.Hour).Unix()), channel)) + require.Equal(t, tc.wantStatus, recorder.Code) + require.Contains(t, recorder.Body.String(), tc.wantBody) + require.NotContains(t, recorder.Body.String(), "video-bucket") + if tc.secretSnippet != "" { + require.NotContains(t, recorder.Body.String(), tc.secretSnippet) + } + text, err := perfmetrics.BuildPrometheusText(context.Background()) + require.NoError(t, err) + require.Contains(t, text, tc.wantMetric) + }) + } +} + +func TestModelAPIVideoProxyWithoutArchiveDoesNotFetchUpstream(t *testing.T) { + restore := useVideoProxyDBForTest(t) + defer restore() + gin.SetMode(gin.TestMode) + + var upstreamHits int + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + upstreamHits++ + w.Header().Set("Content-Type", "video/mp4") + _, _ = w.Write([]byte("upstream bytes")) + })) + defer upstream.Close() + + channel := &model.Channel{ + Id: 11101, + Type: constant.ChannelTypeModelAPISeedance, + Key: "modelapi-key", + Name: "modelapi", + BaseURL: common.GetPointer(upstream.URL), + } + require.NoError(t, model.DB.Create(channel).Error) + require.NoError(t, model.DB.Create(&model.Task{ + TaskID: "task_modelapi_no_archive", + Status: model.TaskStatusSuccess, + ChannelId: channel.Id, + Data: []byte(`{"status":"succeeded","content":[{"type":"video_url","video_url":{"url":"` + upstream.URL + `/output.mp4"}}]}`), + }).Error) + + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Params = gin.Params{{Key: "task_id", Value: "task_modelapi_no_archive"}} + c.Request = httptest.NewRequest(http.MethodGet, "/v1/videos/task_modelapi_no_archive/content", nil) + + VideoProxy(c) + + require.Equal(t, http.StatusBadGateway, recorder.Code) + require.Contains(t, recorder.Body.String(), "video result is unavailable") + require.Equal(t, 0, upstreamHits) +} + func TestLegacyTechMobiVideoProxyUsesExtractorWhenMetadataNil(t *testing.T) { _, c := newArchivedVideoProxyContext("task_legacy") channel := &model.Channel{Type: constant.ChannelTypeTechMobiVideo} @@ -177,9 +297,52 @@ func archivedTechMobiTask(expiresAt int64) *model.Task { } } +func archivedModelAPITask(expiresAt int64) *model.Task { + return &model.Task{ + TaskID: "task_modelapi", + Status: model.TaskStatusSuccess, + PrivateData: model.TaskPrivateData{ + VideoResult: &model.VideoResult{ + Bucket: "video-bucket", + Object: "video-results/20260806/task_modelapi.mp4", + Generation: 7, + ContentType: "video/mp4", + Size: 42, + ExpiresAt: expiresAt, + }, + }, + } +} + func installArchivedVideoResultSigner(t *testing.T, signer func(context.Context, string, *model.VideoResult) (string, error)) { t.Helper() original := signArchivedVideoResultDownload signArchivedVideoResultDownload = signer t.Cleanup(func() { signArchivedVideoResultDownload = original }) } + +func useVideoProxyDBForTest(t *testing.T) func() { + t.Helper() + originalDB := model.DB + originalMemoryCacheEnabled := common.MemoryCacheEnabled + originalUsingSQLite := common.UsingSQLite + originalUsingMySQL := common.UsingMySQL + originalUsingPostgreSQL := common.UsingPostgreSQL + + db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&model.Channel{}, &model.Task{})) + model.DB = db + common.MemoryCacheEnabled = false + common.UsingSQLite = true + common.UsingMySQL = false + common.UsingPostgreSQL = false + + return func() { + model.DB = originalDB + common.MemoryCacheEnabled = originalMemoryCacheEnabled + common.UsingSQLite = originalUsingSQLite + common.UsingMySQL = originalUsingMySQL + common.UsingPostgreSQL = originalUsingPostgreSQL + } +} diff --git a/docs/superpowers/plans/2026-08-10-modelapi-seedance-25-gcs.md b/docs/superpowers/plans/2026-08-10-modelapi-seedance-25-gcs.md new file mode 100644 index 00000000000..1ceb0216031 --- /dev/null +++ b/docs/superpowers/plans/2026-08-10-modelapi-seedance-25-gcs.md @@ -0,0 +1,410 @@ +# ModelAPI Seedance 2.5 GCS Whitelabel Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a standalone ModelAPI Seedance 2.5 upstream channel while keeping all public result URLs on Flatkey and serving archived video bytes through short-lived Google Cloud Storage redirects. + +**Architecture:** Reuse the provider-neutral Seedance request binder and add a focused `modelapiseedance` task adaptor for ModelAPI's `/v1/tasks` wire protocol. Generalize the existing video-result archive hooks to a fixed channel registry, archive before terminal success, persist only Flatkey's `/content` URL, and enforce GCS-only delivery for the new channel. + +**Tech Stack:** Go 1.22+, Gin, existing task adaptor framework, GCS client and V4 signing, React/TypeScript console constants, Bun tests. + +--- + +### Task 1: Lock channel registration and endpoint classification + +**Files:** +- Modify: `constant/channel.go` +- Create: `constant/modelapi_seedance_channel_test.go` +- Modify: `common/endpoint_type.go` +- Modify: `common/endpoint_type_test.go` +- Modify: `relay/relay_adaptor.go` +- Modify: `relay/relay_adaptor_test.go` +- Modify: `relay/channel/task/taskcommon/helpers.go` +- Modify: `relay/channel/task/taskcommon/helpers_test.go` + +- [ ] **Step 1: Write failing registration tests** + +Add assertions for the exact type, name, Base URL, OpenAI Video endpoint, adaptor channel name, white-label membership, and brand scrubbing: + +```go +func TestModelAPISeedanceChannelConstants(t *testing.T) { + require.Equal(t, 111, constant.ChannelTypeModelAPISeedance) + require.Equal(t, "ModelAPISeedance", constant.ChannelTypeNames[111]) + require.Equal(t, "https://api.modelapi.co", constant.ChannelBaseURLs[111]) +} + +func TestGetTaskAdaptor_ModelAPISeedance(t *testing.T) { + adaptor := GetTaskAdaptor(constant.TaskPlatform("111")) + require.NotNil(t, adaptor) + require.Equal(t, "modelapi-seedance", adaptor.GetChannelName()) +} +``` + +- [ ] **Step 2: Run tests and verify RED** + +```powershell +$env:GOCACHE="$PWD\.tmp-gocache" +go test -p 1 ./constant ./common ./relay ./relay/channel/task/taskcommon -run 'ModelAPI|Whitelabel|Scrub' -count=1 +``` + +Expected: failure because the new adaptor and/or complete registration does not exist. + +- [ ] **Step 3: Implement the minimal registration** + +Use `ChannelTypeModelAPISeedance = 111`, default URL `https://api.modelapi.co`, `EndpointTypeOpenAIVideo`, a `GetTaskAdaptor` factory branch, and fixed white-label/brand entries. Avoid unrelated formatting changes in `constant/channel.go`. + +- [ ] **Step 4: Run the Step 2 command and verify GREEN** + +- [ ] **Step 5: Commit with a Lore message** + +```text +Give Seedance 2.5 traffic an isolated upstream protocol boundary + +Constraint: Public video requests remain on the shared Seedance content contract. +Confidence: high +Scope-risk: narrow +Tested: channel constants, endpoint classification, adaptor registration, and white-label detection +``` + +### Task 2: Map Seedance requests to ModelAPI and parse task responses + +**Files:** +- Create: `relay/channel/task/modelapiseedance/constants.go` +- Create: `relay/channel/task/modelapiseedance/types.go` +- Create: `relay/channel/task/modelapiseedance/adaptor.go` +- Create: `relay/channel/task/modelapiseedance/adaptor_test.go` + +- [ ] **Step 1: Write failing mapping and validation tests** + +Tests must call the pure mapping function and `ValidateRequestAndSetAction` for these cases: + +```go +func TestBuildCreateRequestPreservesExplicitFalseAndZero(t *testing.T) { + zero := int64(0) + no := false + req := dto.SeedanceVideoRequest{ + Model: "seedance-2.5", + Content: []dto.SeedanceContent{{Type: "text", Text: "prompt"}}, + Seed: &zero, + GenerateAudio: &no, + Watermark: &no, + } + got, err := buildModelAPICreateRequest(&req) + require.NoError(t, err) + require.Equal(t, "doubao-seedance-2-5-260628", got.Model) + require.NotNil(t, got.Params.Seed) + require.Zero(t, *got.Params.Seed) + require.NotNil(t, got.Params.GenerateAudio) + require.False(t, *got.Params.GenerateAudio) +} +``` + +Add table tests for role mapping, duration 4–30, resolutions, aspect ratios, image/video/audio/total limits, single first/last frame, last-without-first rejection, and invalid roles. + +- [ ] **Step 2: Run mapping tests and verify RED** + +```powershell +$env:GOCACHE="$PWD\.tmp-gocache" +go test -p 1 ./relay/channel/task/modelapiseedance -run 'Build|Validate' -count=1 +``` + +Expected: package or functions do not exist. + +- [ ] **Step 3: Implement request types and pure mapping** + +The outbound types use pointers for optional scalars: + +```go +type createRequest struct { + Model string `json:"model"` + Input createInput `json:"input"` + Params createParams `json:"params"` +} + +type createParams struct { + Duration *int `json:"duration,omitempty"` + Resolution string `json:"resolution,omitempty"` + AspectRatio string `json:"aspect_ratio,omitempty"` + Seed *int64 `json:"seed,omitempty"` + GenerateAudio *bool `json:"generate_audio,omitempty"` + Watermark *bool `json:"watermark,omitempty"` + ReturnLastFrame *bool `json:"return_last_frame,omitempty"` +} +``` + +Use `taskcommon.BindSeedanceRequest`, `common.MarshalNoHTMLEscape`, and no direct `encoding/json` calls. + +- [ ] **Step 4: Write failing submit/fetch/parse tests** + +Use `httptest.Server` to assert: + +- create path is `/v1/tasks`; +- poll path is `/v1/tasks/{upstream_task_id}`; +- `Authorization: Bearer ` is set; +- submit response returns the upstream task ID internally; +- statuses map to queued/in-progress/success/failure; +- success selects the asset whose `type` is `video`, even when it is not first; +- missing video asset returns an error; +- failure reason is scrubbed. + +- [ ] **Step 5: Run response tests and verify RED** + +```powershell +go test -p 1 ./relay/channel/task/modelapiseedance -run 'Request|Response|Fetch|Parse' -count=1 +``` + +- [ ] **Step 6: Implement request, response, status, billing, and OpenAI-video conversion** + +Embed `taskcommon.BaseBilling`. `ConvertToOpenAIVideo` must use `originTask.GetResultURL()` only. `ParseTaskResult` may expose the upstream asset URL only in the in-memory `relaycommon.TaskInfo.Url` field required by the archive stage. + +- [ ] **Step 7: Run all package tests and verify GREEN** + +```powershell +go test -p 1 ./relay/channel/task/modelapiseedance -count=1 +``` + +- [ ] **Step 8: Commit with a Lore message** + +```text +Translate the shared Seedance contract without leaking supplier semantics + +Constraint: Explicit false and zero values must survive the upstream conversion. +Rejected: Provider-specific client input | It would break channel failover and the Seedance SOP. +Confidence: high +Scope-risk: moderate +Tested: request mapping, validation, authentication, submission, polling, and status conversion +``` + +### Task 3: Generalize video-result channel labels with fixed cardinality + +**Files:** +- Create: `service/video_result_channels.go` +- Create: `service/video_result_channels_test.go` +- Modify: `service/video_result_storage.go` +- Modify: `service/video_result_storage_test.go` +- Modify: `pkg/perf_metrics/video_result.go` +- Modify: `pkg/perf_metrics/video_result_test.go` +- Modify: `controller/video_proxy.go` +- Modify: `controller/video_proxy_video_result_test.go` + +- [ ] **Step 1: Write failing label and metric tests** + +```go +func TestVideoResultChannelLabel(t *testing.T) { + require.Equal(t, "techmobi", VideoResultChannelLabel(constant.ChannelTypeTechMobiVideo)) + require.Equal(t, "modelapi", VideoResultChannelLabel(constant.ChannelTypeModelAPISeedance)) + require.Empty(t, VideoResultChannelLabel(constant.ChannelTypeOpenAI)) +} +``` + +Record one `modelapi` archive and redirect and assert the exact Prometheus series are exported. + +- [ ] **Step 2: Run tests and verify RED** + +```powershell +go test -p 1 ./service ./controller ./pkg/perf_metrics -run 'VideoResultChannel|ModelAPI.*Metric|ArchivedModelAPI' -count=1 +``` + +- [ ] **Step 3: Implement the fixed registry and channel-aware archive wrapper** + +Keep the compatibility wrapper: + +```go +func ArchiveVideoResult(ctx context.Context, publicTaskID, upstreamURL, proxy string) (*model.VideoResult, error) { + return ArchiveVideoResultForChannel(ctx, "techmobi", publicTaskID, upstreamURL, proxy) +} +``` + +The generic function accepts only the fixed label derived from the channel registry. Expand metric arrays from `techmobi` to `techmobi,modelapi`; reject/ignore unknown labels through existing index validation. + +- [ ] **Step 4: Run the Step 2 command and verify GREEN** + +- [ ] **Step 5: Commit with a Lore message** + +```text +Reuse durable video delivery without creating unbounded telemetry + +Constraint: Metrics labels are fixed and may never contain upstream or storage identifiers. +Confidence: high +Scope-risk: narrow +Tested: channel registry, archive compatibility wrapper, and fixed Prometheus series +``` + +### Task 4: Archive ModelAPI success before terminal CAS and redact stored polling data + +**Files:** +- Modify: `service/task_polling.go` +- Modify: `service/task_polling_video_result_test.go` +- Modify: `relay/channel/task/modelapiseedance/adaptor.go` +- Modify: `relay/channel/task/modelapiseedance/adaptor_test.go` + +- [ ] **Step 1: Write failing polling tests** + +Add ModelAPI variants proving: + +- archive hook receives the selected upstream URL and proxy; +- persisted `ResultURL` equals `/v1/videos/{public_task_id}/content`; +- `VideoResult` is present at the successful CAS; +- archive failure returns a polling error, leaves the task non-terminal, and does not settle; +- persisted `task.Data` does not contain `https://`, `modelapi`, the upstream host, or the selected asset URL; +- a second node losing the CAS does not settle twice. + +- [ ] **Step 2: Run tests and verify RED** + +```powershell +go test -p 1 ./service -run 'ModelAPI.*Archive|ModelAPI.*Redact|UpdateVideoSingleTask' -count=1 +``` + +- [ ] **Step 3: Generalize the polling archive gate** + +Replace the TechMobi-only condition with `VideoResultChannelLabel(channel.Type)`. For an upstream success: + +```go +label := VideoResultChannelLabel(channel.Type) +if label != "" { + archived, err := archiveVideoResultForChannel(ctx, label, task.TaskID, taskResult.Url, channel.GetProxy()) + if err != nil { return err } + privateData.VideoResult = archived + privateData.ResultURL = taskcommon.BuildVideoContentURL(task.TaskID) +} +``` + +Before assigning the polling body to `task.Data`, call a channel-owned sanitizer that recursively clears asset URLs. Do not log the raw response or URL. + +- [ ] **Step 4: Run service and adaptor tests and verify GREEN** + +```powershell +go test -p 1 ./service ./relay/channel/task/modelapiseedance -run 'ModelAPI|UpdateVideoSingleTask|ParseTaskResult' -count=1 +``` + +- [ ] **Step 5: Commit with a Lore message** + +```text +Do not report generated video success before a durable copy exists + +Constraint: Terminal settlement remains guarded by the existing multi-node CAS. +Rejected: Persisting the upstream asset URL for later download | It leaks supplier data and expires independently. +Confidence: high +Scope-risk: moderate +Directive: New archive channels must register a fixed label and a response sanitizer. +Tested: archive ordering, retry behavior, redaction, and exactly-once settlement +``` + +### Task 5: Keep Flatkey URLs public and make ModelAPI downloads GCS-only + +**Files:** +- Modify: `controller/video_proxy.go` +- Modify: `controller/video_proxy_video_result_test.go` + +- [ ] **Step 1: Write failing controller tests** + +```go +func TestArchivedModelAPIVideoRedirect(t *testing.T) { + // channel 111 + archived metadata + signer hook + // assert 302, exact Location, no-store, and empty response body +} + +func TestModelAPIVideoWithoutArchiveDoesNotFallbackUpstream(t *testing.T) { + // successful ModelAPI task with URL-shaped task.Data and nil VideoResult + // assert safe 502 and assert the upstream httptest server received zero requests +} +``` + +Retain the existing TechMobi legacy fallback test unchanged. + +- [ ] **Step 2: Run tests and verify RED** + +```powershell +go test -p 1 ./controller -run 'ArchivedModelAPI|ModelAPI.*WithoutArchive|LegacyTechMobi' -count=1 +``` + +- [ ] **Step 3: Implement strict ModelAPI content delivery** + +Use the generic archived redirect helper for registered channels. Immediately after it returns false, detect `ChannelTypeModelAPISeedance` and return the safe unavailable response. Do not add a ModelAPI upstream extractor or fallback branch. + +- [ ] **Step 4: Run controller tests and verify GREEN** + +- [ ] **Step 5: Commit with a Lore message** + +```text +Keep Flatkey as the only public video address while Google serves the bytes + +Constraint: ModelAPI tasks may never fall back to an upstream source URL. +Confidence: high +Scope-risk: moderate +Tested: GCS redirect, expiry/storage/signing errors, strict no-archive failure, and TechMobi legacy compatibility +``` + +### Task 6: Add console channel metadata and complete verification + +**Files:** +- Modify: `web/default/src/features/channels/constants.ts` +- Modify: `web/default/src/features/channels/constants.test.ts` +- Modify: `web/default/src/features/channels/lib/channel-type-config.ts` +- Modify: `web/default/src/features/channels/lib/channel-utils.ts` +- Modify: `web/classic/src/constants/channel.constants.js` + +- [ ] **Step 1: Write failing console constant test** + +```ts +test('ModelAPISeedance channel is selectable but not model-fetchable', () => { + expect(CHANNEL_TYPES[111]).toBe('ModelAPISeedance') + expect(MODEL_FETCH_CHANNEL_TYPES).not.toContain(111) +}) +``` + +- [ ] **Step 2: Run the frontend test and verify RED** + +```powershell +Push-Location web/default +bun test src/features/channels/constants.test.ts +Pop-Location +``` + +- [ ] **Step 3: Add channel labels, API-key hint, icon-family mapping, and classic option** + +Reuse an existing Doubao/Seedance icon family; do not add a dependency or public marketing copy. + +- [ ] **Step 4: Run frontend test and build** + +```powershell +Push-Location web/default +bun test src/features/channels/constants.test.ts +bun run build +Pop-Location +``` + +- [ ] **Step 5: Run fresh backend verification** + +```powershell +$env:GOCACHE="$PWD\.tmp-gocache" +go test -p 1 ./constant ./common ./relay/channel/task/taskcommon ./relay/channel/task/modelapiseedance ./service ./controller ./pkg/perf_metrics -count=1 +go test -p 1 ./relay/... -count=1 +go build ./... +go vet ./constant ./common ./relay/... ./service ./controller ./pkg/perf_metrics +git diff --check +rg -n 'encoding/json|api\.modelapi\.co|result\.assets|taskResult\.Url' relay/channel/task/modelapiseedance service controller +``` + +Review every search match: JSON calls must use `common.*`; supplier host and asset URLs may appear only in internal constants/parsers/tests, never client messages or logs. + +- [ ] **Step 6: Request independent code review and fix every Critical/Important finding** + +The review must explicitly report: + +- `Router deploy: required`; +- `Console deploy: required`; +- `Other deploy targets: no website/Terraform/Cloudflare`; +- staging live-channel validation is required before production. + +- [ ] **Step 7: Re-run the affected tests after review fixes and commit** + +```text +Expose the new video supplier through existing Flatkey administration surfaces + +Constraint: The supplier is configurable only as an internal channel and is not public marketing content. +Confidence: high +Scope-risk: moderate +Tested: console constants, frontend build, targeted Go tests, relay tests, full Go build, vet, and diff checks +Not-tested: Live upstream generation and production deployment +``` diff --git a/docs/superpowers/plans/2026-08-11-modelapi-seedance-25-billing.md b/docs/superpowers/plans/2026-08-11-modelapi-seedance-25-billing.md new file mode 100644 index 00000000000..ff40e7fb47a --- /dev/null +++ b/docs/superpowers/plans/2026-08-11-modelapi-seedance-25-billing.md @@ -0,0 +1,295 @@ +# ModelAPI Seedance 2.5 Billing Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make `doubao-seedance-2-5-260628` reserve and settle the documented upstream price while preserving Flatkey's existing group, wallet, and subscription accounting. + +**Architecture:** Keep the existing fixed-price asynchronous-task pipeline. The adaptor converts either request fields or the upstream `usage.estimated_usd` task snapshot into one `billable_units` multiplier over a `$0.14` base price; the relay layer remains responsible for group ratio, precharge, submit delta settlement, wallet funding, and subscription weighting. + +**Tech Stack:** Go, Gin reusable request bodies, existing `TaskAdaptor` billing hooks, Go `testing`, GitNexus. + +--- + +## File structure + +- Create `relay/channel/task/modelapiseedance/billing_test.go` for reservation, validation, usage normalization, and correction behavior. +- Modify `relay/channel/task/modelapiseedance/constants.go` for provider prices and the single billing-unit key. +- Modify `relay/channel/task/modelapiseedance/adaptor.go` for the billing hooks and private submit snapshot. +- Create `setting/ratio_setting/modelapi_seedance_price_test.go` for the default-price regression. +- Modify `setting/ratio_setting/model_ratio.go` for the `$0.14` calculation base. +- Modify `relay/relay_task_billing_test.go` for group-ratio-preserving submit recalculation. + +### Task 1: Implement adaptor reservation and submit correction + +**Files:** +- Create: `relay/channel/task/modelapiseedance/billing_test.go` +- Modify: `relay/channel/task/modelapiseedance/constants.go` +- Modify: `relay/channel/task/modelapiseedance/adaptor.go` + +- [ ] **Step 1: Write failing request-time reservation tests** + +Use the existing `newModelAPITestContext` helper, call `ValidateRequestAndSetAction`, set `types.PriceData{UsePrice: true, ModelPrice: 0.14}`, and assert that `EstimateBilling` returns exactly one ratio named `billingUnitsKey`. + +```go +tests := []struct { + name string + body string + wantUnits float64 +}{ + {"default 5s 720p", `{"model":"doubao-seedance-2-5-260628","content":[{"type":"text","text":"hello"}]}`, 0.314 * 5 / 0.14}, + {"text 480p", `{"model":"doubao-seedance-2-5-260628","content":[{"type":"text","text":"hello"}],"duration":8,"resolution":"480p"}`, 8}, + {"text 720p", `{"model":"doubao-seedance-2-5-260628","content":[{"type":"text","text":"hello"}],"duration":8,"resolution":"720p"}`, 0.314 * 8 / 0.14}, + {"video 480p fallback", `{"model":"doubao-seedance-2-5-260628","content":[{"type":"video_url","video_url":{"url":"https://example.com/input.mp4"}}],"resolution":"480p"}`, 0.084 * 30 / 0.14}, + {"video 720p fallback", `{"model":"doubao-seedance-2-5-260628","content":[{"type":"video_url","video_url":{"url":"https://example.com/input.mp4"}}],"resolution":"720p"}`, 0.188 * 30 / 0.14}, +} +``` + +- [ ] **Step 2: Verify the reservation test is RED** + +Run `go test ./relay/channel/task/modelapiseedance -run TestEstimateBillingUsesOfficialSeedanceRates -count=1`. + +Expected: compile failure because `billingUnitsKey` and custom request billing do not exist. + +- [ ] **Step 3: Write failing fixed-price validation tests** + +Assert one valid fixed price passes. Assert `UsePrice=false`, zero, negative, `math.NaN()`, and `math.Inf(1)` each return `model_price_error` with HTTP 400. + +```go +valid := &relaycommon.RelayInfo{PriceData: types.PriceData{UsePrice: true, ModelPrice: 0.14}} +if taskErr := (&TaskAdaptor{}).ValidateTaskPriceData(valid); taskErr != nil { + t.Fatalf("valid price rejected: %+v", taskErr) +} +``` + +- [ ] **Step 4: Write failing response-snapshot tests** + +Submit a response with `"usage":{"estimated_usd":1.57}`. Assert `DoResponse` succeeds, the public response contains neither `estimated_usd` nor the upstream task id, and `AdjustBillingOnSubmit` returns `1.57 / 0.14` billable units. + +Table-test missing usage, `usage:null`, estimate `null`, zero, negative, string `"NaN"`, and overflowing `1e999`. Each must keep the task successful and return no submit adjustment. Also assert malformed private task data and invalid price data return no adjustment. + +- [ ] **Step 5: Verify all new billing tests are RED** + +Run `go test ./relay/channel/task/modelapiseedance -run 'TestEstimateBilling|TestValidateTaskPriceData|TestDoResponse.*Estimate|TestAdjustBillingOnSubmit' -count=1`. + +Expected: compile failure for missing channel-specific billing methods and types. + +- [ ] **Step 6: Add the exact provider constants** + +```go +const ( + modelAPIBasePriceUSD = 0.14 + modelAPINoVideo480PPerSecondUSD = 0.140 + modelAPINoVideo720PPerSecondUSD = 0.314 + modelAPIVideo480PPerSecondUSD = 0.084 + modelAPIVideo720PPerSecondUSD = 0.188 + modelAPIDefaultDurationSeconds = 5 + modelAPIMaxDurationSeconds = 30 + billingUnitsKey = "billable_units" +) +``` + +- [ ] **Step 7: Implement the minimal request-time hooks** + +```go +func (a *TaskAdaptor) ValidateTaskPriceData(info *relaycommon.RelayInfo) *dto.TaskError { + if info == nil || !info.PriceData.UsePrice || !isPositiveFinite(info.PriceData.ModelPrice) { + return taskError(errors.New("a positive fixed model price is required"), "model_price_error", http.StatusBadRequest) + } + return nil +} + +func (a *TaskAdaptor) EstimateBilling(c *gin.Context, info *relaycommon.RelayInfo) map[string]float64 { + if info == nil || !info.PriceData.UsePrice || !isPositiveFinite(info.PriceData.ModelPrice) { + return nil + } + req, err := taskcommon.GetSeedanceRequest(c) + if err != nil { + return nil + } + resolution := req.Resolution + if resolution == "" { + resolution = "720p" + } + estimatedUSD, ok := estimateModelAPIUSD(req, resolution) + if !ok { + return nil + } + return billingUnits(estimatedUSD, info.PriceData.ModelPrice) +} +``` + +`estimateModelAPIUSD` uses duration 5 when absent, uses the explicit request duration for no-video requests, and uses 30 seconds for both video-input fallbacks. Unsupported resolutions return `(0, false)`. + +- [ ] **Step 8: Implement tolerant usage parsing and correction** + +Use `json.RawMessage` in the upstream usage type so an optional string or overflowing number does not reject an otherwise valid task. + +```go +type modelAPIUsage struct { + EstimatedUSD json.RawMessage `json:"estimated_usd"` +} + +type modelAPISubmitTaskData struct { + Status string `json:"status,omitempty"` + EstimatedUSD *float64 `json:"estimated_usd,omitempty"` +} + +func normalizeEstimatedUSD(raw json.RawMessage) *float64 { + if len(raw) == 0 || string(raw) == "null" { + return nil + } + var value float64 + if err := common.Unmarshal(raw, &value); err != nil || !isPositiveFinite(value) { + return nil + } + return &value +} + +func billingUnits(estimatedUSD, modelPrice float64) map[string]float64 { + if !isPositiveFinite(estimatedUSD) || !isPositiveFinite(modelPrice) { + return nil + } + units := estimatedUSD / modelPrice + if !isPositiveFinite(units) { + return nil + } + return map[string]float64{billingUnitsKey: units} +} + +func (a *TaskAdaptor) AdjustBillingOnSubmit(info *relaycommon.RelayInfo, taskData []byte) map[string]float64 { + if info == nil || !info.PriceData.UsePrice || !isPositiveFinite(info.PriceData.ModelPrice) { + return nil + } + var snapshot modelAPISubmitTaskData + if err := common.Unmarshal(taskData, &snapshot); err != nil || snapshot.EstimatedUSD == nil { + return nil + } + return billingUnits(*snapshot.EstimatedUSD, info.PriceData.ModelPrice) +} +``` + +Change `DoResponse` to marshal only `modelAPISubmitTaskData{Status: submit.Status, EstimatedUSD: normalized}` into private task data. Keep the public `OpenAIVideo` response unchanged. + +- [ ] **Step 9: Verify Task 1 is GREEN** + +Run: + +```powershell +gofmt -w relay/channel/task/modelapiseedance/constants.go relay/channel/task/modelapiseedance/adaptor.go relay/channel/task/modelapiseedance/billing_test.go +go test ./relay/channel/task/modelapiseedance -count=1 +``` + +Expected: all ModelAPI Seedance adaptor tests pass. + +- [ ] **Step 10: Commit Task 1** + +Use a Lore commit whose intent is `Make Seedance 2.5 reservations follow the upstream task snapshot`, recording the 30-second video fallback, rejection of media probing, focused test command, and live-API validation gap. + +### Task 2: Register the base price and guard generic settlement + +**Files:** +- Create: `setting/ratio_setting/modelapi_seedance_price_test.go` +- Modify: `setting/ratio_setting/model_ratio.go` +- Modify: `relay/relay_task_billing_test.go` + +- [ ] **Step 1: Add and run the failing default-price test** + +```go +func TestModelAPISeedanceDefaultPrice(t *testing.T) { + if got := GetDefaultModelPriceMap()["doubao-seedance-2-5-260628"]; got != 0.14 { + t.Fatalf("doubao-seedance-2-5-260628 default price = %v, want 0.14", got) + } +} +``` + +Run `go test ./setting/ratio_setting -run TestModelAPISeedanceDefaultPrice -count=1`. + +Expected: failure because the map lookup returns zero. + +- [ ] **Step 2: Add the fixed-price calculation base** + +Add this entry near the other video models in `defaultModelPrice`: + +```go +// ModelAPI Seedance 2.5 calculation base; the adaptor converts each +// request/task snapshot into a complete billable_units multiplier. +"doubao-seedance-2-5-260628": 0.14, +``` + +- [ ] **Step 3: Add the group-ratio replacement regression test** + +```go +func TestModelAPISeedanceSubmitAdjustmentPreservesGroupRatio(t *testing.T) { + const modelPrice, groupRatio, reservedUSD, actualUSD = 0.14, 0.8, 0.314 * 5, 1.25 + reservedUnits := reservedUSD / modelPrice + info := &relaycommon.RelayInfo{PriceData: types.PriceData{ + ModelPrice: modelPrice, UsePrice: true, + Quota: int(modelPrice * common.QuotaPerUnit * groupRatio * reservedUnits), + OtherRatios: map[string]float64{"billable_units": reservedUnits}, + GroupRatioInfo: types.GroupRatioInfo{GroupRatio: groupRatio}, + }} + got := recalcQuotaFromRatios(info, map[string]float64{"billable_units": actualUSD / modelPrice}) + want := int(actualUSD * common.QuotaPerUnit * groupRatio) + if got != want { + t.Fatalf("recalcQuotaFromRatios() = %d, want %d", got, want) + } +} +``` + +- [ ] **Step 4: Verify Task 2 is GREEN and existing funding paths remain authoritative** + +Run: + +```powershell +gofmt -w setting/ratio_setting/model_ratio.go setting/ratio_setting/modelapi_seedance_price_test.go relay/relay_task_billing_test.go +go test ./setting/ratio_setting -count=1 +go test ./relay -run 'ModelAPISeedance|Billing' -count=1 +go test ./controller -run 'TestAssetTaskWorkerAcceptedWinnerSettlesAndLogsOnce|TestAssetTaskWorkerAcceptedSubscriptionUsesSnapshotForSettlement' -count=1 +``` + +Expected: the default-price and group-ratio tests pass; the existing wallet delta and subscription-weighted settlement guards also pass. + +- [ ] **Step 5: Commit Task 2** + +Use a Lore commit whose intent is `Give Seedance 2.5 a stable fixed-price billing base`, recording that synthetic model aliases were rejected, `$0.14` remains only the calculation base, and production balances were not exercised. + +### Task 3: Review, verify, and publish + +**Files:** +- Verify all Task 1 and Task 2 files. +- Remove the temporary untracked `.gitnexusignore`. + +- [ ] **Step 1: Run a spec-compliance review, then a code-quality review, for each task commit** + +Compare actual code against this plan and `docs/superpowers/specs/2026-08-11-modelapi-seedance-25-billing-design.md`. Resolve every Critical or Important finding and repeat the relevant review. + +- [ ] **Step 2: Run full release verification** + +```powershell +go test ./relay/channel/task/modelapiseedance -count=1 +go test ./setting/ratio_setting -count=1 +go test ./relay -run 'ModelAPISeedance|Billing' -count=1 +go test ./controller -run 'TestAssetTaskWorkerAcceptedWinnerSettlesAndLogsOnce|TestAssetTaskWorkerAcceptedSubscriptionUsesSnapshotForSettlement' -count=1 +go build ./... +git diff --check +``` + +Expected: every command exits zero and `git diff --check` prints nothing. + +- [ ] **Step 3: Remove `.gitnexusignore` with `apply_patch` and run GitNexus** + +```powershell +$node='C:\Users\11247\.cache\codex-runtimes\codex-primary-runtime\dependencies\node\bin\node.exe' +$env:PATH='C:\Users\11247\.cache\codex-runtimes\codex-primary-runtime\dependencies\node\bin;'+$env:PATH +& $node 'C:\nvm4w\nodejs\node_modules\npm\bin\npx-cli.js' -y 'gitnexus@1.6.9' detect-changes --repo new-api-modelapi-seedance-worktree --scope compare --base-ref origin/main +``` + +Expected: the billing delta introduces no new critical dependency impact. + +- [ ] **Step 4: Request final whole-feature review** + +Review `origin/main..HEAD` for pricing correctness, optional JSON tolerance, privacy, duplicate settlement, quota rounding, and fixed-price failure behavior. Resolve every Critical or Important issue and re-review. + +- [ ] **Step 5: Push and create the requested PR** + +Push `feature/modelapi-seedance-25` and create a PR with `--base main`. The PR body must include the official documentation URL, all four price formulas, fallback rationale, private `estimated_usd` correction, existing Google-backed download behavior, deployment scope, verification evidence, and the live-environment gap. diff --git a/docs/superpowers/plans/2026-08-11-modelapi-seedance-25-url-native-assets.md b/docs/superpowers/plans/2026-08-11-modelapi-seedance-25-url-native-assets.md new file mode 100644 index 00000000000..648ecb5327e --- /dev/null +++ b/docs/superpowers/plans/2026-08-11-modelapi-seedance-25-url-native-assets.md @@ -0,0 +1,385 @@ +# ModelAPI Seedance 2.5 URL-Native Assets Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Let ModelAPI Seedance 2.5 consume Flatkey `asset://` references through fresh, per-submission GCS V4 HTTPS URLs without creating upstream asset bindings. + +**Architecture:** Keep the normal model-to-channel binding and coverage-target flow, but classify `ChannelTypeModelAPISeedance` as a URL-native asset target with deterministic scope `source-url:modelapi`. Readiness is activated from recoverable Flatkey source state, and the selected-channel middleware re-queries every referenced asset before signing any URL. Binding/materializer channels retain their existing path. + +**Tech Stack:** Go 1.22+, Gin, GORM, SQLite test databases, existing `AssetObjectStore` fake, `httptest`, GCS V4 signing abstraction. + +--- + +### Task 1: Declare Seedance 2.5 and ModelAPI URL-native target capability + +**Files:** +- Modify: `service/asset_model_scope.go` +- Modify: `service/asset_model_target.go` +- Modify: `service/asset_reference.go` +- Test: `service/asset_model_target_test.go` +- Test: `service/asset_model_scope_test.go` + +- [ ] **Step 1: Write failing capability and target tests** + +Add table cases proving all three spellings are reusable and a ModelAPI channel remains target-eligible without a materializer: + +```go +for _, modelName := range []string{ + "doubao-seedance-2.5-260628", + "doubao-seedance-2-5-260628", + "doubao-seedance-2_5-260628", +} { + require.True(t, assetModelHasReusableAssetCapability(modelName)) +} + +channel := &model.Channel{ + Id: 925, + Type: constant.ChannelTypeModelAPISeedance, + Status: common.ChannelStatusEnabled, + Models: "doubao-seedance-2-5-260628", +} +require.True(t, assetModelChannelEligible(scope, channel)) +candidates := assetModelCandidatesForChannel(channel, "doubao-seedance-2-5-260628") +require.Len(t, candidates, 1) +require.Equal(t, "source-url:modelapi", candidates[0].BindingScope) +require.Equal(t, -1, candidates[0].CredentialIndex) +``` + +- [ ] **Step 2: Run the RED tests** + +Run: + +```powershell +$env:GOCACHE='E:\go-cache\build' +$env:GOTMPDIR='E:\go-cache\tmp' +go test -vet=off -p 1 ./service -run 'AssetModelHasReusableAssetCapability|ModelAPI.*Target|ResolveAssetModelScope.*Seedance25' -count=1 +``` + +Expected: FAIL because 2.5 spellings are excluded and ModelAPI has no materializer. + +- [ ] **Step 3: Implement the minimal capability helpers** + +Use one deterministic scope and one channel-type predicate: + +```go +const assetModelSourceURLScopeModelAPI = "source-url:modelapi" + +func AssetModelChannelUsesSourceURL(channelType int) bool { + return channelType == constant.ChannelTypeModelAPISeedance +} + +func assetModelTargetUsesSourceURL(target model.AssetModelCoverageTarget) bool { + return strings.TrimSpace(target.BindingScope) == assetModelSourceURLScopeModelAPI +} +``` + +Extend `assetModelHasReusableAssetCapability` with `2.5`, `2-5`, and `2_5`. In `assetModelChannelEligible`, permit ModelAPI before the materializer check. In `assetModelCandidatesForChannel`, emit `source-url:modelapi` without calling `assetBindingScope`. Add ModelAPI to `channelCanConsumeAssetType` for Image, Video, and Audio. + +- [ ] **Step 4: Run the GREEN tests** + +Run the same command. Expected: PASS. + +### Task 2: Activate URL-native readiness without `AssetBinding` + +**Files:** +- Modify: `service/asset_model_worker.go` +- Test: `service/asset_model_worker_test.go` + +- [ ] **Step 1: Write a failing worker test** + +Seed an active ModelAPI target with `BindingScope: "source-url:modelapi"`, a recoverable GCS asset, and a claimed readiness row. Run `PrepareAssetModelReadiness` and assert: + +```go +require.Equal(t, model.AssetModelReadinessStatusActive, row.Status) +require.Equal(t, int64(0), countRows(t, &model.AssetBinding{})) +require.Equal(t, 0, materializerCreateCalls) +require.Equal(t, 0, signerCalls) +``` + +- [ ] **Step 2: Run the RED test** + +```powershell +go test -vet=off -p 1 ./service -run 'TestAssetModelWorkerModelAPI.*WithoutBinding' -count=1 +``` + +Expected: FAIL because the worker resolves target options and calls `prepareAssetModelBinding`. + +- [ ] **Step 3: Add the URL-native worker branch** + +After loading and re-validating the selected target/channel, finish readiness directly for the URL-native target: + +```go +if assetModelTargetUsesSourceURL(*target) { + if !AssetModelChannelUsesSourceURL(channel.Type) { + return finishAssetModelReadinessFailed(row, owner, nowUnix, "target_unavailable") + } + return finishAssetModelReadinessActive(row, owner, nowUnix) +} +``` + +Keep the existing `ResolveAssetModelTargetOptions` and binding path unchanged for every other target. + +- [ ] **Step 4: Run the GREEN test and binding-channel regressions** + +```powershell +go test -vet=off -p 1 ./service -run 'TestAssetModelWorker(ModelAPI|TechMobi|BytePlus|ActivationRecovery)' -count=1 +``` + +Expected: PASS. + +### Task 3: Project URL-native status and available models without a binding + +**Files:** +- Modify: `service/asset_model_status.go` +- Modify: `service/asset_reference.go` +- Test: `service/asset_model_status_test.go` +- Test: `service/asset_reference_test.go` + +- [ ] **Step 1: Write failing status and channel-ranking tests** + +Cover both sides of the lifecycle boundary: + +```go +// Recoverable source + active matching readiness + no AssetBinding. +require.Equal(t, model.AssetStatusActive, result.Status) +require.Equal(t, []string{"doubao-seedance-2-5-260628"}, result.AvailableModels) + +// Same rows, but SourceExpiresAt <= now. +require.NotEqual(t, model.AssetStatusActive, expired.Status) +require.Empty(t, expired.AvailableModels) +``` + +Also assert `AssetReferenceSet.ReadinessForChannel` returns `AssetReadinessVerifiedTarget` only while the current source is recoverable. + +- [ ] **Step 2: Run the RED tests** + +```powershell +go test -vet=off -p 1 ./service -run 'Test.*ModelAPI.*(Status|Available|Readiness|SourceExpires)' -count=1 +``` + +Expected: FAIL because active binding keys are mandatory and stale source time is not checked for URL-native targets. + +- [ ] **Step 3: Centralize target satisfaction** + +Use a single helper in both status projection functions: + +```go +func assetModelTargetReadyForAsset(asset model.Asset, target model.AssetModelCoverageTarget, bindings activeAssetBindingKeySet) bool { + if assetModelTargetUsesSourceURL(target) { + return assetModelSourceRecoverableAt(asset, assetNow()) + } + return bindings.has(target) +} +``` + +Thread `asset` into `availableAssetModelsForScope`. In `targetReadinessForChannel`, require `assetReferenceSourceRecoverable` instead of a binding for URL-native targets. Do not change binding requirements for BytePlus, TechMobi, or other materializer channels. + +- [ ] **Step 4: Run GREEN and compatibility tests** + +```powershell +go test -vet=off -p 1 ./service -run 'AssetModelStatus|AssetReference.*Readiness|TechMobi|BytePlus' -count=1 +``` + +Expected: PASS. + +### Task 4: Resolve and sign fresh HTTPS source URLs in two phases + +**Files:** +- Create: `service/asset_source_url.go` +- Create: `service/asset_source_url_test.go` + +- [ ] **Step 1: Write failing resolver tests** + +Create tests for fresh signing, no persistence, and all-or-nothing validation: + +```go +first, err := ResolveAssetSourceURLRewriteMap(ctx, userID, references, channel, modelName) +require.NoError(t, err) +second, err := ResolveAssetSourceURLRewriteMap(ctx, userID, references, channel, modelName) +require.NoError(t, err) +require.NotEqual(t, first[assetURI], second[assetURI]) +require.Equal(t, 2, signer.calls) + +var bindingCount int64 +require.NoError(t, model.DB.Model(&model.AssetBinding{}).Count(&bindingCount).Error) +require.Zero(t, bindingCount) +requireDatabaseContainsNoString(t, "signed.example") +``` + +For a set containing one valid and one expired asset, assert the resolver returns an error and `signer.calls == 0`. Add ownership, type mismatch, unsupported backend, missing object metadata, and non-HTTPS signer result cases. + +- [ ] **Step 2: Run the RED tests** + +```powershell +go test -vet=off -p 1 ./service -run 'TestResolveAssetSourceURLRewriteMap' -count=1 +``` + +Expected: FAIL to compile because the resolver does not exist. + +- [ ] **Step 3: Implement two-phase resolution** + +Expose one service entry point: + +```go +func ResolveAssetSourceURLRewriteMap( + ctx context.Context, + userID int, + references AssetReferenceSet, + channel *model.Channel, + originModel string, +) (map[string]string, error) +``` + +Phase one re-queries all public IDs with `model.GetAssetsWithBindingsByPublicIDsForUser(userID, ids)` and validates every item, the selected active target, selected channel, mapped model, type, lifecycle, GCS source metadata, and `SourceExpiresAt > now`. Phase two calls `SignAssetSourceURL(ctx, asset, CurrentAssetStorageConfig())` once per distinct asset and rejects any result whose parsed scheme is not exactly `https`. No signed value is assigned to a model or written with GORM. + +- [ ] **Step 4: Run GREEN** + +Run the same command. Expected: PASS with only fake signer calls. + +### Task 5: Route selected ModelAPI channels through the URL-native resolver + +**Files:** +- Modify: `middleware/distributor.go` +- Test: `middleware/distributor_byteplus_asset_test.go` +- Test: `controller/asset_task_worker_test.go` + +- [ ] **Step 1: Write failing immediate and queued-path tests** + +For the immediate path, call `RefreshAssetRewriteMapForSelectedChannel` with a ModelAPI channel and assert the context receives a fresh HTTPS map. For the queued worker, reuse the specific-channel restoration test and capture the rewrite map immediately before `PrepareTaskAttempt`: + +```go +rewriteMap, ok := common.GetContextKeyType[map[string]string](c, constant.ContextKeyAssetRewriteMap) +require.True(t, ok) +require.Equal(t, "https", mustParseURL(t, rewriteMap[assetURI]).Scheme) +``` + +Use fake object storage and a fake task adaptor; no request may leave the process. + +- [ ] **Step 2: Run the RED tests** + +```powershell +go test -vet=off -p 1 ./middleware ./controller -run 'TestModelAPISeedance(Immediate|Queued).*Rewrite' -count=1 +``` + +Expected: FAIL because middleware enters the binding/materializer path. + +- [ ] **Step 3: Add the middleware branch before materialization** + +```go +if service.AssetModelChannelUsesSourceURL(channel.Type) { + rewriteMap, err := service.ResolveAssetSourceURLRewriteMap(ctx, userID, references, channel, originModel) + if err != nil { + clearAssetRewriteMap(c) + return service.AssetBindingAPIError(err) + } + setAssetRewriteMaps(c, rewriteMap) + return nil +} +``` + +Retain the existing binding/materialize code byte-for-byte after this branch. Both call sites continue to use the same middleware function, so queued signing occurs after context restoration and channel selection. + +- [ ] **Step 4: Run GREEN** + +Run the same command. Expected: PASS. + +### Task 6: Rewrite before ModelAPI validation and require HTTPS + +**Files:** +- Modify: `relay/channel/task/modelapiseedance/adaptor.go` +- Modify: `relay/channel/task/modelapiseedance/adaptor_test.go` +- Create: `relay/channel/task/modelapiseedance/main_test.go` + +- [ ] **Step 1: Write failing adaptor tests** + +Add tests proving the real call order succeeds and unsafe input fails: + +```go +require.Nil(t, adaptor.ValidateRequestAfterModelMapping(c, info)) +body, err := adaptor.BuildRequestBody(c, info) +require.NoError(t, err) +require.NotContains(t, readBody(t, body), `"url":"asset://`) + +// Rewrite value uses http://. +_, err = adaptor.BuildRequestBody(httpRewriteContext, info) +require.ErrorContains(t, err, "invalid asset reference") +``` + +Cover missing rewrite, empty rewrite, residual `asset://`, malformed asset URI, and plain prompt text containing `asset://` that must remain text. + +- [ ] **Step 2: Run the RED tests** + +```powershell +go test -vet=off -p 1 ./relay/channel/task/modelapiseedance -run 'BuildRequestBody|ValidateRequestAfterModelMapping|HTTPS|AssetReference' -count=1 +``` + +Expected: FAIL because validation currently runs before rewrite and remote HTTP URLs are accepted. + +- [ ] **Step 3: Implement validation-phase rewrite and HTTPS-only validation** + +Create a helper that binds the reusable body, applies `ContextKeyAssetRewriteMap`, validates, and updates the cached request. Call it from `ValidateRequestAfterModelMapping`; keep `BuildRequestBody` defensive by applying the same rewrite and validation again. In `validateModelAPIMediaURL`, parse the URL after the shared SSRF-safe validation and require `strings.EqualFold(parsed.Scheme, "https")`. + +Remove the production `flag` import, `flag.Lookup("test.v")`, and `rejectLiveModelAPIRequestDuringTests` call sites. Put test-only network safety in `main_test.go`: + +```go +func TestMain(m *testing.M) { + _ = os.Setenv("HTTP_PROXY", "http://127.0.0.1:1") + _ = os.Setenv("HTTPS_PROXY", "http://127.0.0.1:1") + _ = os.Setenv("ALL_PROXY", "http://127.0.0.1:1") + _ = os.Setenv("NO_PROXY", "127.0.0.1,localhost,::1") + os.Exit(m.Run()) +} +``` + +- [ ] **Step 4: Run GREEN** + +Run the same command. Expected: PASS without network traffic. + +### Task 7: Preserve the existing archive lease and redirect-hardening fixes + +**Files:** +- Verify existing changes: `model/task.go` +- Verify existing changes: `model/task_cas_test.go` +- Verify existing changes: `service/task_polling.go` +- Verify existing changes: `service/task_polling_video_result_test.go` +- Verify existing changes: `service/video_result_storage.go` +- Verify existing changes: `service/video_result_storage_test.go` + +- [ ] **Step 1: Run the focused existing tests offline** + +```powershell +go test -vet=off -p 1 ./model -run 'TaskVideoResultArchiveLease' -count=1 +go test -vet=off -p 1 ./service -run 'UpdateVideoSingleTaskModelAPIArchive|ModelAPICASLoser|VideoResult.*Redirect' -count=1 +``` + +Expected: PASS. Do not weaken owner/expiry/task-ID fences or redirect-by-redirect SSRF validation while implementing URL-native assets. + +### Task 8: Verify, review, and prepare the PR update + +**Files:** +- Review all files changed since `main` + +- [ ] **Step 1: Run affected-package verification with network blocked** + +```powershell +$env:HTTP_PROXY='http://127.0.0.1:1' +$env:HTTPS_PROXY='http://127.0.0.1:1' +$env:ALL_PROXY='http://127.0.0.1:1' +$env:NO_PROXY='127.0.0.1,localhost,::1' +$env:GOCACHE='E:\go-cache\build' +$env:GOTMPDIR='E:\go-cache\tmp' +go test -vet=off -p 1 ./model ./service ./middleware ./controller ./relay/channel/task/modelapiseedance -count=1 +go vet ./model ./service ./middleware ./controller ./relay/channel/task/modelapiseedance +go build ./... +git diff --check +``` + +Expected: all commands exit 0. + +- [ ] **Step 2: Run spec-compliance review, then code-quality review** + +Review against `docs/superpowers/specs/2026-08-11-modelapi-seedance-25-url-native-assets-design.md`. Fix every important finding, re-run the relevant target tests, and request re-review. + +- [ ] **Step 3: Commit with Lore trailers and update PR #683** + +Stage only intended files. The commit message must record the URL-native boundary, no-live-network constraint, tests run, and remaining full-suite gaps. Push `feature/modelapi-seedance-25`, then reply to the actionable comment on `SolveaCX/new-api#683` with the fixing commit and verification evidence. diff --git a/docs/superpowers/specs/2026-08-10-modelapi-seedance-25-gcs-design.md b/docs/superpowers/specs/2026-08-10-modelapi-seedance-25-gcs-design.md new file mode 100644 index 00000000000..8e611aac0a1 --- /dev/null +++ b/docs/superpowers/specs/2026-08-10-modelapi-seedance-25-gcs-design.md @@ -0,0 +1,159 @@ +# ModelAPI Seedance 2.5 白标接入与 GCS 下载设计 + +日期:2026-08-10 + +## 目标 + +把 ModelAPI 的 `doubao-seedance-2-5-260628` 作为 Flatkey 的独立 Seedance 2.5 上游渠道。客户端继续使用 Flatkey 现有的 `POST /v1/videos`、官方 Seedance `content[]` 入参和任务查询协议。 + +成功任务对外始终返回 Flatkey 自己的内容地址: + +```text +/v1/videos/{public_task_id}/content +``` + +客户端访问该地址时,Flatkey 生成短时 Google Cloud Storage V4 Signed URL 并返回 `302 Found`。因此 API 地址和品牌归属保持为 Flatkey,实际视频字节由 Google 下载链路承载。 + +## 已确认约束 + +- 上游创建接口:`POST https://api.modelapi.co/v1/tasks`。 +- 上游查询接口:`GET https://api.modelapi.co/v1/tasks/{task_id}`。 +- 鉴权使用 `Authorization: Bearer `。 +- 上游模型固定为 `doubao-seedance-2-5-260628`,不透传客户端模型名。 +- 上游状态为 `pending | polling | running | succeeded | failed`。 +- 成功视频从 `result.assets[]` 中选择 `type == "video"` 的项目,不能依赖数组顺序。 +- 成功任务必须先完成 GCS 归档,再通过现有 CAS 路径进入最终成功状态和结算。 +- 上游真实任务 ID、响应 URL、品牌、主机名、GCS 桶名和 Signed URL 不得出现在客户端 JSON 或应用日志中。 +- 新渠道的任务数据不得保存可恢复的上游视频 URL;`TaskPrivateData.ResultURL` 只保存 Flatkey `/content` 地址。 +- 对本渠道,缺少有效 `VideoResult` 元数据时 `/content` 返回安全错误,不回退到上游直链或 Cloud Run 字节代理。 +- 生产为多节点;归档依赖现有确定性对象键、GCS generation precondition 和任务状态 CAS,不引入进程内正确性锁。 + +## 方案选择 + +### 采用:独立渠道适配器 + 复用现有 GCS 结果归档 + +新增独立 `ChannelTypeModelAPISeedance` 和 `relay/channel/task/modelapiseedance` 适配器。入站继续复用 `dto.SeedanceVideoRequest` 与 `taskcommon.BindSeedanceRequest`,只在适配器内完成 ModelAPI wire-format 映射。结果复用现有 `ArchiveVideoResult`、`SignVideoResultDownload` 和 `TaskPrivateData.VideoResult`,仅将原先 TechMobi 专用的调用点泛化为固定白名单渠道。 + +该方案最小化新增安全面,并保留现有 GCS 幂等、MP4 校验、SSRF 防护、保留期和签名逻辑。 + +### 未采用:直接把上游 URL 返回给客户端 + +会暴露供应商和真实资源地址,失去 Flatkey 白标边界,并让可用期依赖上游临时 URL。 + +### 未采用:新增第二套 Google 存储实现或公开桶 + +会重复已有归档与签名能力。公开桶或永久对象 URL无法满足短时授权和隐藏存储标识的要求。 + +## 请求映射 + +客户端仍发送 `dto.SeedanceVideoRequest`。适配器生成: + +```json +{ + "model": "doubao-seedance-2-5-260628", + "input": { + "text": [{"role": "prompt", "content": "..."}], + "image": [{"role": "reference", "url": "..."}], + "video": [{"role": "reference", "url": "..."}], + "audio": [{"role": "reference", "url": "..."}] + }, + "params": { + "duration": 5, + "resolution": "720p", + "aspect_ratio": "adaptive", + "seed": 1, + "generate_audio": false, + "watermark": false, + "return_last_frame": false + } +} +``` + +可选标量使用指针和 `omitempty`,保证显式 `false`、`0` 与未提供字段语义不同。所有 JSON 编解码调用 `common.*` 包装函数。 + +Seedance 素材角色映射如下: + +| Seedance role | ModelAPI role | +| --- | --- | +| `reference_image` | `reference` | +| `reference_video` | `reference` | +| `reference_audio` | `reference` | +| 空 role | `reference` | +| `first_frame` | `first_frame` | +| `last_frame` | `last_frame` | + +非法 role 在提交前返回 `400 invalid_request`。同时提前验证:`duration` 4–30、`resolution` 为 `480p|720p`、宽高比为官方集合、图片不超过 30、视频不超过 10、音频不超过 10、总素材不超过 50、首帧和尾帧各最多一项、尾帧必须与首帧同时出现。 + +## 创建与轮询 + +创建成功后仅把上游任务 ID保存为任务内部 ID,客户端拿到随机公开任务 ID。轮询状态映射: + +| 上游状态 | Flatkey 状态 | +| --- | --- | +| `pending` | `QUEUED` | +| `polling`、`running` | `IN_PROGRESS` | +| `succeeded` | 先归档,成功后 `SUCCESS` | +| `failed` | `FAILURE`,错误信息脱敏 | + +轮询响应进入 `task.Data` 前必须移除所有 asset URL。成功解析时,真实视频 URL只存在于当前轮询调用的内存对象中,供归档器立即读取;不得写日志。 + +## 成功数据流 + +1. 后台轮询 ModelAPI 任务。 +2. 从 `result.assets[]` 选择 `type == "video"` 的 URL。 +3. 调用现有 GCS 归档器,以固定指标标签 `modelapi` 流式下载、校验并写入私有结果桶。 +4. 归档成功后,把 `VideoResult` 写入任务私有数据,并把 `ResultURL` 设置为 Flatkey `/v1/videos/{public_task_id}/content`。 +5. 使用现有任务状态 CAS 完成成功转换和一次性结算。若其他节点已完成转换,本节点不重复结算。 +6. 客户端查询任务时只看到 Flatkey 地址。 +7. 客户端访问 `/content`;Flatkey 校验任务、渠道、过期时间和对象 generation,生成短时 Signed URL并返回 `302`。 +8. 客户端跟随跳转后从 Google 下载,Google 处理 Range、Content-Length 和实际字节吞吐。 + +归档失败时本轮不推进成功状态,下一轮重新查询并重试。这样不会出现“任务显示成功但没有可下载副本”的状态。 + +## 下载与错误语义 + +ModelAPI 渠道采用严格归档模式: + +- 有有效 `VideoResult`:`302` 到短时 GCS Signed URL,并设置 `Cache-Control: no-store`。 +- 对象过期:`410 Gone`。 +- 对象缺失或属性不匹配:`502 Bad Gateway`。 +- 签名服务临时失败:`503 Service Unavailable`。 +- 成功任务缺少 `VideoResult`:返回安全的 `502`,不尝试解析上游 URL,也不代理上游流量。 + +TechMobi 的历史任务兼容回退保持不变;严格禁止回退仅适用于新 ModelAPI 渠道。 + +## 注册与控制台 + +- 后端渠道类型值使用 `111`,默认 Base URL 为 `https://api.modelapi.co`。 +- `GetTaskAdaptor` 注册新的任务适配器。 +- endpoint 类型注册为 OpenAI Video。 +- 加入 Seedance 白标渠道集合和品牌词脱敏集合。 +- 默认控制台和 classic 控制台都显示独立渠道类型;模型列表固定为 `doubao-seedance-2-5-260628`,不调用通用模型抓取。 + +## 安全与可观测性 + +- 复用已有结果桶与运行时凭证,不新增公开 IAM、静态密钥或永久 URL。 +- 指标只使用固定 `channel=modelapi` 标签,不使用任务 ID、URL、桶或对象名,避免高基数。 +- 日志只记录公开任务 ID、阶段、状态码、字节数和耗时;不记录请求 Authorization、上游任务 ID、上游 URL或 Signed URL。 +- 存储在 `task.Data` 的轮询快照必须经过 URL 清除;失败原因经过 `ScrubBrandedText`。 + +## 测试与验收 + +- 请求映射覆盖文本、图片、视频、音频、首尾帧和显式 `false/0`。 +- 参数和素材限制在发送上游前失败。 +- 创建、查询 URL、鉴权头和状态映射正确。 +- 成功 asset 选择按 `type=video`,不依赖第一项。 +- 成功轮询必须先归档;归档失败不成功、不结算。 +- `task.Data` 与日志不包含上游 URL、主机名或品牌。 +- 查询结果中的 URL始终为 Flatkey `/content`。 +- `/content` 对归档结果返回 GCS `302`;缺元数据时不回退上游。 +- 现有 TechMobi 历史回退测试继续通过。 +- 运行相关 Go 测试、`go build ./...`、`go vet` 和控制台定向测试。 + +## 发布建议 + +- Router deploy:required。新增 `/v1/videos` 渠道路由、请求适配和 `/content` 下载分支均在 router 请求路径。 +- Console deploy:required。主节点负责异步轮询、GCS 归档和任务状态持久化。 +- Website:not required。 +- Terraform / Cloudflare:not required;沿用已部署的私有结果桶和现有环境变量。 +- 先在 staging 配置独立渠道与密钥,完成真实创建、轮询、GCS 对象、Flatkey 地址和 `302` 下载验证后再发布生产。 diff --git a/docs/superpowers/specs/2026-08-11-modelapi-seedance-25-billing-design.md b/docs/superpowers/specs/2026-08-11-modelapi-seedance-25-billing-design.md new file mode 100644 index 00000000000..3c01cdc6616 --- /dev/null +++ b/docs/superpowers/specs/2026-08-11-modelapi-seedance-25-billing-design.md @@ -0,0 +1,124 @@ +# ModelAPI Seedance 2.5 Billing Design + +## Goal + +Reuse the existing asynchronous task billing hooks so `doubao-seedance-2-5-260628` is precharged before submission and corrected from the upstream task-price snapshot immediately after a successful submit response. + +The public model name and `/v1/videos` contract remain unchanged. Billing must account for both pricing dimensions documented by the upstream: video-input presence and output resolution. + +## Official pricing contract + +Source checked on 2026-08-11: + +| Video input | Resolution | Price formula | +| --- | --- | --- | +| No | `480p` | `$0.140 * duration` | +| No | `720p` | `$0.314 * duration` | +| Yes | `480p` | `$0.084 * total_video_duration` | +| Yes | `720p` | `$0.188 * total_video_duration` | + +The documented request defaults are `duration=5` and `resolution=720p`; supported duration is 4 through 30 seconds. A successful `POST /v1/tasks` response may include `usage.estimated_usd` as either a number or `null`. The pricing note says actual billing uses the task pricing snapshot and may include valid operational or user discounts. + +The ModelAPI document does not define how it derives `total_video_duration` when media duration is omitted. Flatkey therefore must not claim that a locally calculated video-input amount is exact. + +## Selected design + +Use the existing three-stage task billing seam: + +1. `EstimateBilling` calculates a safe request-time reservation expressed as a multiplier over a `$0.14` base `ModelPrice`. +2. `DoResponse` persists a valid, positive, finite `usage.estimated_usd` inside private task submission data. +3. `AdjustBillingOnSubmit` converts the upstream dollar estimate back into billing units and lets the existing relay settlement path reconcile the reservation immediately. + +The default model price is `$0.14`. This is a calculation base, not a claim that every request costs `$0.14`. + +One `OtherRatios` entry represents the complete billable-unit multiplier: + +```text +billable_units = estimated_usd / model_price +quota = model_price * billable_units * group_ratio * quota_per_unit +``` + +Using one complete multiplier avoids compounding or rounding drift across separate duration, resolution, and video-input ratios. + +## Request-time reservation + +For requests without video input, the amount is fully determined from public request fields: + +```text +480p: 0.140 * resolved_duration +720p: 0.314 * resolved_duration +``` + +Missing duration resolves to 5. Missing resolution resolves to `720p`. + +For requests with video input, Flatkey cannot reliably inspect arbitrary remote media or reproduce the upstream definition of `total_video_duration`. The fallback reservation uses the maximum supported request duration, 30 seconds, at the matching video-input rate: + +```text +480p: 0.084 * 30 +720p: 0.188 * 30 +``` + +This is a bounded reservation fallback, not an asserted final price. A non-null upstream `estimated_usd` replaces it during submit settlement. If the upstream returns `null`, the reservation remains in place rather than guessing a lower amount or making the task free. + +## Submit-response correction + +`modelAPISubmitResponse` gains a typed usage object. `DoResponse` accepts `estimated_usd` only when it is positive and finite. It persists only the status and normalized numeric estimate required for billing; the public response remains the Flatkey task object and does not expose supplier billing metadata. + +`AdjustBillingOnSubmit` reads the private submission snapshot and returns a replacement `OtherRatios` map when all of the following are true: + +- task data is valid JSON; +- `estimated_usd` exists and is positive and finite; +- fixed-price billing is active; +- `ModelPrice` is positive and finite. + +Otherwise it returns no adjustment, preserving the request-time reservation. + +## Fail-closed price configuration + +This channel must use fixed-price billing. A channel-specific `ValidateTaskPriceData` rejects ratio fallback, zero or negative model prices, and non-finite values. This prevents an administrator setting or permissive unset-ratio mode from silently producing free or nonsensical video jobs. + +The existing group-ratio, wallet, and subscription paths remain authoritative. The adapter supplies only billable units; it does not bypass `PreConsumeBilling`, `SettleBilling`, subscription weighting, refunds, or persisted `TaskBillingContext` snapshots. + +## Alternatives considered + +### Four synthetic public model names + +Rejected because callers must continue using one official model name. Encoding price tiers into model aliases would leak billing implementation into routing and make fallback between Seedance channels unsafe. + +### Billing-expression tiers + +Rejected for this channel because the upstream already returns a per-task price snapshot and the public request does not contain a trustworthy `total_video_duration`. A new expression and metadata pipeline would duplicate the existing task billing hooks without improving accuracy. + +### Local media probing + +Rejected because fetching customer-controlled media during preflight expands SSRF, latency, bandwidth, and timeout risk. It still would not guarantee parity with the upstream's duration calculation. + +## Multi-node behavior + +No process-local state is introduced. Request parsing remains scoped to the Gin request context; the reservation and corrected billing values flow through the existing billing session and are persisted in the task billing snapshot. Every router instance performs the same deterministic calculation from the request and submit response. + +## Error and privacy behavior + +- Invalid or missing pricing configuration fails before upstream submission. +- Invalid or null `estimated_usd` does not fail an otherwise valid task; it retains the reservation. +- Upstream supplier names, hosts, internal task IDs, raw responses, and private asset URLs remain absent from public responses. +- No new client-visible endpoint or response field is introduced. + +## Test contract + +Tests must cover: + +- default `5s/720p` no-video reservation; +- explicit duration for no-video `480p` and `720p` requests; +- video-input fallback reservations for `480p` and `720p`; +- resolution and video-input detection through the shared Seedance request parser; +- valid `usage.estimated_usd` persistence and submit-time correction; +- `null`, missing, zero, negative, malformed, and non-finite estimate fallback behavior; +- fixed-price validation, including ratio fallback and invalid model prices; +- the default `$0.14` model-price entry; +- group-ratio preservation and the existing wallet/subscription settlement paths; +- no public response or persisted public task data leakage. + +## Deployment impact + +`Router deploy: required` because the change affects `/v1/videos` relay precharge and settlement. `newapi-console` also needs the same backend build so pricing configuration and model metadata stay consistent across the production split. `newapi-web`, Terraform, Cloudflare, and the decommissioned legacy service are not involved. diff --git a/docs/superpowers/specs/2026-08-11-modelapi-seedance-25-url-native-assets-design.md b/docs/superpowers/specs/2026-08-11-modelapi-seedance-25-url-native-assets-design.md new file mode 100644 index 00000000000..80f636d3430 --- /dev/null +++ b/docs/superpowers/specs/2026-08-11-modelapi-seedance-25-url-native-assets-design.md @@ -0,0 +1,98 @@ +# ModelAPI Seedance 2.5 URL-Native Asset Design + +## Goal + +Allow `doubao-seedance-2-5-260628` requests routed through the ModelAPI Seedance channel to consume Flatkey asset-library references without inventing an upstream asset-library API that ModelAPI does not document. + +The production channel binding is not skipped. Administrators still bind the public model to an enabled `ModelAPISeedance` channel, and that channel continues to participate in routing, fixed-price billing, health, and enable/disable controls. Only upstream asset materialization is skipped for this channel. + +## Upstream contract boundary + +The ModelAPI documentation exposes task submission and polling only: + +- `POST /v1/tasks` +- `GET /v1/tasks/{task_id}` + +Its task input accepts HTTPS or base64 media in `input.image[]`, `input.video[]`, and `input.audio[]`. It does not expose an asset create, asset bind, or asset lookup API. Flatkey must therefore treat ModelAPI as a URL-native consumer instead of creating provider-side asset identifiers. + +ModelAPI request limits remain enforced before submission: at most 30 images, 10 videos, 10 audio items, and 50 media items in total. + +## Selected design + +Introduce an internal URL-native target capability for `ChannelTypeModelAPISeedance`. The deterministic target scope is `source-url:modelapi`. + +For this target: + +- target selection still persists an active `AssetModelCoverageTarget`; +- readiness still persists one row per asset and model; +- the readiness worker validates the current source and target, then activates readiness with the existing CAS transition; +- the worker does not resolve a materializer, call a provider, sign a URL, create an `AssetBinding`, or persist an upstream asset ID; +- strict readiness and available-model projections require an active matching readiness row and a recoverable source, but do not require an active binding. + +All binding-based channels retain their existing behavior. + +## Source lifecycle and status + +A URL-native target is usable only while the Flatkey source is recoverable: + +- the asset belongs to the submitting user; +- the requested media type matches the stored asset type; +- the asset lifecycle is active; +- `SourceStatus` is available; +- storage backend, bucket, and object key are present and supported; +- `SourceExpiresAt` is strictly greater than the current time. + +`SourceExpiresAt <= now` fails closed even if a stale active status or historical binding remains in the database. Status reconciliation must stop reporting the ModelAPI model as active/available once the source expires. + +## Per-submission rewrite + +After a ModelAPI channel is selected, `RefreshAssetRewriteMapForSelectedChannel` takes the URL-native branch before the existing materialization branch. + +The resolver performs two phases: + +1. Re-query all referenced assets by public ID and user ID, then validate ownership, type, lifecycle, source recoverability, selected target, and selected channel for every reference. +2. Only after every reference passes, generate a fresh GCS V4 GET URL for every distinct source and require each result to use the `https` scheme. + +This ordering guarantees that one expired or invalid reference results in zero signer calls. The returned map uses `asset://` keys and short-lived HTTPS values. Neither the map nor any signed URL is written to `AssetBinding`, `AssetModelReadiness`, `AssetModelCoverageTarget`, `Task`, or `Asset` rows. + +Repeated submissions must generate new signed URLs. Queued submissions rebuild their asset reference context and use the same middleware branch, so a URL is signed at actual submit time rather than queue-enqueue time. + +## Adaptor behavior + +The ModelAPI adaptor must apply the rewrite map before media URL validation in both `ValidateRequestAfterModelMapping` and `BuildRequestBody`. + +The adaptor fails closed when: + +- an `asset://` reference is malformed; +- a required rewrite entry is missing; +- a rewrite value is empty, non-HTTPS, or still uses an asset scheme; +- the final media URL is HTTP. + +The upstream request body must never contain an unresolved Flatkey asset URI. Plain-text prompt mentions of `asset://...` are not rewritten. + +## Test-network safety + +No automated test may call ModelAPI or GCS. Tests use SQLite, the existing fake asset object store, and `httptest.Server` only. + +Package-level test setup sets `HTTP_PROXY`, `HTTPS_PROXY`, and `ALL_PROXY` to `http://127.0.0.1:1`, with loopback in `NO_PROXY`. Any live-host test guard is test-owned; production code must not inspect Go test flags. + +## Compatibility + +- BytePlus, TechMobi, BlockRun, and other binding/materializer channels continue to require and use `AssetBinding`. +- The public request remains `POST /v1/videos` with `asset://` references. +- The upstream ModelAPI payload receives HTTPS media URLs. +- The public result remains the Flatkey `/content` URL, and successful downloads continue to redirect to Google Cloud Storage. +- Fixed-price two-stage billing remains unchanged. + +## Acceptance criteria + +- Seedance 2.5 spellings using `2.5`, `2-5`, and `2_5` enter the reusable-asset model scope. +- An enabled ModelAPI channel is target-eligible without a materializer. +- URL-native readiness becomes active without provider calls or binding rows. +- Strict status and available-model projection work without a binding while the source is recoverable. +- Source expiry immediately removes URL-native availability. +- Each submission obtains a fresh HTTPS rewrite map; signed URLs are absent from database rows. +- Any invalid or expired member prevents all signing. +- Both immediate and queued submission paths use the URL-native resolver. +- The validation-then-build adaptor call order succeeds for mapped assets and rejects HTTP or missing rewrites. +- All tests remain offline and do not consume ModelAPI balance. diff --git a/dto/video_seedance.go b/dto/video_seedance.go index 4e421891fd9..c7c835dfa5e 100644 --- a/dto/video_seedance.go +++ b/dto/video_seedance.go @@ -125,10 +125,10 @@ func (r *SeedanceVideoRequest) HasFirstLastFrame() bool { } // Validate enforces the minimal seedance contract: a text prompt OR at least -// one image/video reference must be present. +// one media reference must be present. func (r *SeedanceVideoRequest) Validate() error { - if strings.TrimSpace(r.PromptText()) == "" && len(r.Images()) == 0 && len(r.Videos()) == 0 { - return errors.New("seedance request requires a text prompt or at least one image/video") + if strings.TrimSpace(r.PromptText()) == "" && len(r.Images()) == 0 && len(r.Videos()) == 0 && len(r.Audios()) == 0 { + return errors.New("seedance request requires a text prompt or at least one image/video/audio") } return nil } diff --git a/dto/video_seedance_test.go b/dto/video_seedance_test.go index c733809def5..1d85cbc45b7 100644 --- a/dto/video_seedance_test.go +++ b/dto/video_seedance_test.go @@ -1,8 +1,9 @@ package dto import ( - "encoding/json" "testing" + + "github.com/QuantumNous/new-api/common" ) func TestSeedanceVideoRequest_JSONTags(t *testing.T) { @@ -17,7 +18,7 @@ func TestSeedanceVideoRequest_JSONTags(t *testing.T) { "return_last_frame":true,"callback_url":"https://cb" }` var r SeedanceVideoRequest - if err := json.Unmarshal([]byte(raw), &r); err != nil { + if err := common.Unmarshal([]byte(raw), &r); err != nil { t.Fatalf("unmarshal: %v", err) } if r.Model != "kuaizi-lizhen-pro" || len(r.Content) != 2 { @@ -85,6 +86,11 @@ func TestSeedanceVideoRequest_Validate(t *testing.T) { req: SeedanceVideoRequest{Content: []SeedanceContentItem{{Type: SeedanceContentImage, ImageURL: &SeedanceURLObject{URL: "https://a/i.jpg"}}}}, wantErr: false, }, + { + name: "audio only ok", + req: SeedanceVideoRequest{Content: []SeedanceContentItem{{Type: SeedanceContentAudio, AudioURL: &SeedanceURLObject{URL: "https://a/a.mp3"}}}}, + wantErr: false, + }, { name: "empty fails", req: SeedanceVideoRequest{}, diff --git a/middleware/distributor.go b/middleware/distributor.go index c1ee8e892d0..ca70780f749 100644 --- a/middleware/distributor.go +++ b/middleware/distributor.go @@ -460,6 +460,38 @@ func RefreshAssetRewriteMapForSelectedChannel(c *gin.Context, channel *model.Cha return nil } originModel := strings.TrimSpace(c.GetString("original_model")) + ctx := context.Background() + if c.Request != nil { + ctx = c.Request.Context() + } + if service.AssetModelChannelUsesSourceURL(channel.Type) { + modelInfo := &relaycommon.RelayInfo{ + OriginModelName: originModel, + ChannelMeta: &relaycommon.ChannelMeta{UpstreamModelName: originModel}, + } + if err := relayhelper.ModelMappedHelper(c, modelInfo, nil); err != nil { + clearAssetRewriteMap(c) + return bytePlusAssetDistributionError(types.ErrorCodeInvalidAssetRequest, http.StatusBadRequest) + } + rewriteMap, err := service.ResolveAssetSourceURLRewriteMap( + ctx, + common.GetContextKeyInt(c, constant.ContextKeyUserId), + references, + channel, + originModel, + ) + if err != nil { + clearAssetRewriteMap(c) + return service.AssetBindingAPIError(err) + } + if len(rewriteMap) == 0 { + clearAssetRewriteMap(c) + return nil + } + common.SetContextKey(c, constant.ContextKeyAssetRewriteMap, rewriteMap) + common.SetContextKey(c, constant.ContextKeyBytePlusAssetRewriteMap, rewriteMap) + return nil + } if !common.GetContextKeyBool(c, constant.ContextKeyAssetMaterializeEnabled) { rewriteMap := references.RewriteMapForSelectedChannel( channel, @@ -474,10 +506,6 @@ func RefreshAssetRewriteMapForSelectedChannel(c *gin.Context, channel *model.Cha common.SetContextKey(c, constant.ContextKeyBytePlusAssetRewriteMap, rewriteMap) return nil } - ctx := context.Background() - if c.Request != nil { - ctx = c.Request.Context() - } modelInfo := &relaycommon.RelayInfo{ OriginModelName: originModel, ChannelMeta: &relaycommon.ChannelMeta{UpstreamModelName: originModel}, diff --git a/model/task.go b/model/task.go index c600f8ce2f0..07bc0189b69 100644 --- a/model/task.go +++ b/model/task.go @@ -2,6 +2,7 @@ package model import ( "bytes" + "context" "database/sql/driver" "encoding/json" "strings" @@ -86,13 +87,15 @@ type Task struct { Properties Properties `json:"properties" gorm:"type:json"` Username string `json:"username,omitempty" gorm:"-"` // 禁止返回给用户,内部可能包含key等隐私信息 - PrivateData TaskPrivateData `json:"-" gorm:"column:private_data;type:json"` - Data json.RawMessage `json:"data" gorm:"type:json"` - PreparationStatus string `json:"-" gorm:"type:varchar(24);index"` - NormalizedRequestPayload json.RawMessage `json:"-" gorm:"type:json"` - PreparationLeaseOwner string `json:"-" gorm:"type:varchar(64);index"` - PreparationLeaseExpiresAt int64 `json:"-" gorm:"index"` - PreparationAttemptCount int `json:"-"` + PrivateData TaskPrivateData `json:"-" gorm:"column:private_data;type:json"` + Data json.RawMessage `json:"data" gorm:"type:json"` + PreparationStatus string `json:"-" gorm:"type:varchar(24);index"` + NormalizedRequestPayload json.RawMessage `json:"-" gorm:"type:json"` + PreparationLeaseOwner string `json:"-" gorm:"type:varchar(64);index"` + PreparationLeaseExpiresAt int64 `json:"-" gorm:"index"` + PreparationAttemptCount int `json:"-"` + VideoResultArchiveLeaseOwner string `json:"-" gorm:"type:varchar(64);index"` + VideoResultArchiveLeaseExpiresAt int64 `json:"-" gorm:"index;default:0"` AcceptedAccountingStatus string `json:"-" gorm:"type:varchar(24);index"` AcceptedAccountingLeaseOwner string `json:"-" gorm:"type:varchar(64);index"` @@ -278,7 +281,8 @@ func InitTask(platform constant.TaskPlatform, relayInfo *commonRelay.RelayInfo) if relayInfo != nil && relayInfo.ChannelMeta != nil { if relayInfo.ChannelMeta.ChannelType == constant.ChannelTypeGemini || relayInfo.ChannelMeta.ChannelType == constant.ChannelTypeVertexAi || - relayInfo.ChannelMeta.ChannelType == constant.ChannelTypeTechMobiVideo { + relayInfo.ChannelMeta.ChannelType == constant.ChannelTypeTechMobiVideo || + relayInfo.ChannelMeta.ChannelType == constant.ChannelTypeModelAPISeedance { privateData.Key = relayInfo.ChannelMeta.ApiKey } if relayInfo.UpstreamModelName != "" { @@ -596,6 +600,73 @@ func (t *Task) UpdateWithStatus(fromStatus TaskStatus) (bool, error) { return result.RowsAffected > 0, nil } +func ClaimTaskVideoResultArchiveLease(taskID string, fromStatus TaskStatus, owner string, now int64, leaseExpiresAt int64) (bool, error) { + result := DB.Model(&Task{}). + Where("task_id = ? AND status = ?", taskID, fromStatus). + Where("(video_result_archive_lease_expires_at IS NULL OR video_result_archive_lease_expires_at <= ?)", now). + Updates(map[string]any{ + "video_result_archive_lease_owner": owner, + "video_result_archive_lease_expires_at": leaseExpiresAt, + "updated_at": now, + }) + if result.Error != nil { + return false, result.Error + } + return result.RowsAffected == 1, nil +} + +func ReleaseTaskVideoResultArchiveLease(taskID string, fromStatus TaskStatus, owner string, expectedLeaseExpiresAt int64, now int64) (bool, error) { + return releaseTaskVideoResultArchiveLease(DB, taskID, fromStatus, owner, expectedLeaseExpiresAt, now) +} + +func ReleaseTaskVideoResultArchiveLeaseWithContext(ctx context.Context, taskID string, fromStatus TaskStatus, owner string, expectedLeaseExpiresAt int64, now int64) (bool, error) { + return releaseTaskVideoResultArchiveLease(DB.WithContext(ctx), taskID, fromStatus, owner, expectedLeaseExpiresAt, now) +} + +func RenewTaskVideoResultArchiveLease(taskID string, fromStatus TaskStatus, owner string, expectedLeaseExpiresAt int64, now int64, leaseExpiresAt int64) (bool, error) { + result := DB.Model(&Task{}). + Where("task_id = ? AND status = ?", taskID, fromStatus). + Where("video_result_archive_lease_owner = ? AND video_result_archive_lease_expires_at = ? AND video_result_archive_lease_expires_at > ?", owner, expectedLeaseExpiresAt, now). + Updates(map[string]any{ + "video_result_archive_lease_expires_at": leaseExpiresAt, + "updated_at": now, + }) + if result.Error != nil { + return false, result.Error + } + return result.RowsAffected == 1, nil +} + +func releaseTaskVideoResultArchiveLease(db *gorm.DB, taskID string, fromStatus TaskStatus, owner string, expectedLeaseExpiresAt int64, now int64) (bool, error) { + result := db.Model(&Task{}). + Where("task_id = ? AND status = ?", taskID, fromStatus). + Where("video_result_archive_lease_owner = ? AND video_result_archive_lease_expires_at = ?", owner, expectedLeaseExpiresAt). + Updates(map[string]any{ + "video_result_archive_lease_owner": "", + "video_result_archive_lease_expires_at": 0, + "updated_at": now, + }) + if result.Error != nil { + return false, result.Error + } + return result.RowsAffected == 1, nil +} + +func (t *Task) UpdateWithStatusAndVideoResultArchiveLease(fromStatus TaskStatus, owner string, expectedLeaseExpiresAt int64, now int64) (bool, error) { + t.VideoResultArchiveLeaseOwner = "" + t.VideoResultArchiveLeaseExpiresAt = 0 + result := DB.Model(t). + Where("task_id = ?", t.TaskID). + Where("status = ?", fromStatus). + Where("video_result_archive_lease_owner = ? AND video_result_archive_lease_expires_at = ? AND video_result_archive_lease_expires_at > ?", owner, expectedLeaseExpiresAt, now). + Select("*"). + Updates(t) + if result.Error != nil { + return false, result.Error + } + return result.RowsAffected > 0, nil +} + func ClaimTaskPreparationLease(taskID string, owner string, expectedAttemptCount int, now int64, leaseExpiresAt int64) (bool, error) { result := DB.Model(&Task{}). Where("task_id = ? AND status = ?", taskID, TaskStatusQueued). diff --git a/model/task_key_test.go b/model/task_key_test.go index fbf5d9511eb..42b1ef12281 100644 --- a/model/task_key_test.go +++ b/model/task_key_test.go @@ -21,6 +21,18 @@ func TestInitTaskPersistsTechMobiSelectedKeyForPolling(t *testing.T) { require.Equal(t, "techmobi-selected-key", task.PrivateData.Key) } +func TestInitTaskPersistsModelAPISeedanceSelectedKeyForPolling(t *testing.T) { + task := InitTask(constant.TaskPlatform("49"), &relaycommon.RelayInfo{ + UserId: 7, + ChannelMeta: &relaycommon.ChannelMeta{ + ChannelType: constant.ChannelTypeModelAPISeedance, + ApiKey: "modelapi-selected-key", + }, + }) + + require.Equal(t, "modelapi-selected-key", task.PrivateData.Key) +} + func TestTechMobiSubmittingFencePreservesSelectedKeyAfterExpiry(t *testing.T) { truncateTables(t) diff --git a/pkg/perf_metrics/video_result.go b/pkg/perf_metrics/video_result.go index 671f610585a..dbf1fd5061f 100644 --- a/pkg/perf_metrics/video_result.go +++ b/pkg/perf_metrics/video_result.go @@ -9,7 +9,7 @@ import ( ) const ( - videoResultChannelCount = 1 + videoResultChannelCount = 2 videoResultArchiveOutcomeCount = 3 videoResultArchiveDurationBucketCount = 13 videoResultRedirectOutcomeCount = 4 @@ -17,7 +17,7 @@ const ( ) var ( - videoResultChannels = [videoResultChannelCount]string{"techmobi"} + videoResultChannels = [videoResultChannelCount]string{"techmobi", "modelapi"} videoResultArchiveOutcomes = [videoResultArchiveOutcomeCount]string{"success", "failure", "reuse"} videoResultRedirectOutcomes = [videoResultRedirectOutcomeCount]string{"success", "expired", "unavailable", "signing-or-other"} videoResultArchiveRetryReasons = [videoResultArchiveRetryReasonCount]string{"archive_failure"} diff --git a/pkg/perf_metrics/video_result_test.go b/pkg/perf_metrics/video_result_test.go index 1cd62cb34db..7f12abce3f3 100644 --- a/pkg/perf_metrics/video_result_test.go +++ b/pkg/perf_metrics/video_result_test.go @@ -32,6 +32,53 @@ func TestVideoResultMetricsExportArchiveRedirectAndRetryCounters(t *testing.T) { requirePrometheusSeriesGaugeMatchesRenderedSamples(t, text) } +func TestModelAPIVideoResultMetricsExportArchiveRedirectAndRetryCounters(t *testing.T) { + resetPerfMetricsStateForTest(t) + resetVideoResultMetricsWithCleanup(t) + + RecordVideoResultArchive("modelapi", "success", 321, 3*time.Second) + RecordVideoResultRedirect("modelapi", "success") + RecordVideoResultRedirect("modelapi", "expired") + RecordVideoResultArchiveRetry("modelapi", "archive_failure") + + text, err := BuildPrometheusText(context.Background()) + require.NoError(t, err) + requirePrometheusSampleLine(t, text, `newapi_video_result_archive_total{channel="modelapi",outcome="success"} 1`) + requirePrometheusSampleLine(t, text, `newapi_video_result_archive_bytes_total{channel="modelapi"} 321`) + requirePrometheusSampleLine(t, text, `newapi_video_result_redirect_total{channel="modelapi",outcome="success"} 1`) + requirePrometheusSampleLine(t, text, `newapi_video_result_redirect_total{channel="modelapi",outcome="expired"} 1`) + requirePrometheusSampleLine(t, text, `newapi_video_result_archive_retry_total{channel="modelapi",reason="archive_failure"} 1`) + requirePrometheusSampleLine(t, text, `newapi_video_result_archive_duration_seconds_count{channel="modelapi"} 1`) + requirePrometheusSampleLine(t, text, `newapi_video_result_archive_duration_seconds_sum{channel="modelapi"} 3`) + requirePrometheusSeriesGaugeMatchesRenderedSamples(t, text) +} + +func TestModelAPIUnknownMetricLabelsDoNotGrowCardinality(t *testing.T) { + resetPerfMetricsStateForTest(t) + resetVideoResultMetricsWithCleanup(t) + + RecordVideoResultArchive("modelapi", "success", 321, time.Second) + text, err := BuildPrometheusText(context.Background()) + require.NoError(t, err) + seriesBefore := prometheusSampleValue(t, text, "newapi_perf_metrics_series") + + RecordVideoResultArchive("modelapi/task_1", "success", 999, time.Second) + RecordVideoResultArchive("modelapi", "task_1", 999, time.Second) + RecordVideoResultRedirect("modelapi/task_1", "success") + RecordVideoResultRedirect("modelapi", "https://signed.example/object") + RecordVideoResultArchiveRetry("modelapi", "task_1") + + text, err = BuildPrometheusText(context.Background()) + require.NoError(t, err) + require.Equal(t, seriesBefore, prometheusSampleValue(t, text, "newapi_perf_metrics_series")) + require.NotContains(t, text, "modelapi/task_1") + require.NotContains(t, text, "task_1") + require.NotContains(t, text, "signed.example") + requirePrometheusSampleLine(t, text, `newapi_video_result_archive_total{channel="modelapi",outcome="success"} 1`) + requirePrometheusSampleLine(t, text, `newapi_video_result_archive_bytes_total{channel="modelapi"} 321`) + requirePrometheusSeriesGaugeMatchesRenderedSamples(t, text) +} + func TestVideoResultMetricsCountsBytesOnlyForSuccessfulArchives(t *testing.T) { resetPerfMetricsStateForTest(t) resetVideoResultMetricsWithCleanup(t) @@ -119,7 +166,11 @@ func TestVideoResultMetricsUseOnlyClosedLabelValues(t *testing.T) { if !strings.HasPrefix(line, "newapi_video_result_") { continue } - require.Contains(t, line, `channel="techmobi"`) + require.True(t, + strings.Contains(line, `channel="techmobi"`) || strings.Contains(line, `channel="modelapi"`), + "unexpected video result channel label in %q", + line, + ) require.NotContains(t, line, "task_") require.NotContains(t, line, "video-results/") require.NotContains(t, line, "http") diff --git a/relay/channel/task/modelapiseedance/adaptor.go b/relay/channel/task/modelapiseedance/adaptor.go new file mode 100644 index 00000000000..b461491f0d4 --- /dev/null +++ b/relay/channel/task/modelapiseedance/adaptor.go @@ -0,0 +1,719 @@ +package modelapiseedance + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "math" + "net/http" + "net/url" + "strings" + "time" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/constant" + "github.com/QuantumNous/new-api/dto" + "github.com/QuantumNous/new-api/model" + "github.com/QuantumNous/new-api/relay/channel" + "github.com/QuantumNous/new-api/relay/channel/task/taskcommon" + relaycommon "github.com/QuantumNous/new-api/relay/common" + "github.com/QuantumNous/new-api/service" + + "github.com/gin-gonic/gin" +) + +type TaskAdaptor struct { + taskcommon.BaseBilling + ChannelType int + apiKey string + baseURL string +} + +func (a *TaskAdaptor) Init(info *relaycommon.RelayInfo) { + if info == nil { + a.ChannelType = constant.ChannelTypeModelAPISeedance + a.baseURL = constant.ChannelBaseURLs[constant.ChannelTypeModelAPISeedance] + return + } + a.ChannelType = info.ChannelType + a.apiKey = info.ApiKey + a.baseURL = strings.TrimRight(strings.TrimSpace(info.ChannelBaseUrl), "/") + if a.baseURL == "" { + a.baseURL = constant.ChannelBaseURLs[constant.ChannelTypeModelAPISeedance] + } + info.UpstreamModelName = UpstreamModel + if info.ChannelMeta != nil { + info.ChannelMeta.UpstreamModelName = UpstreamModel + } +} + +func (a *TaskAdaptor) ValidateRequestAndSetAction(c *gin.Context, info *relaycommon.RelayInfo) *dto.TaskError { + seedReq, err := taskcommon.BindSeedanceRequest(c, info, constant.TaskActionGenerate) + if err != nil { + return taskError(err, "invalid_request", http.StatusBadRequest) + } + if err := validateModelAPISeedanceRequest(seedReq); err != nil { + return taskError(err, "invalid_request", http.StatusBadRequest) + } + info.UpstreamModelName = UpstreamModel + if info.ChannelMeta != nil { + info.ChannelMeta.UpstreamModelName = UpstreamModel + } + return nil +} + +func (a *TaskAdaptor) ValidateRequestAfterModelMapping(c *gin.Context, info *relaycommon.RelayInfo) *dto.TaskError { + if c == nil || c.Request == nil || c.Request.URL == nil || c.Request.Method != http.MethodPost || c.Request.URL.Path != "/v1/videos" { + return taskError(fmt.Errorf("this channel type is only available on /v1/videos"), "invalid_request", http.StatusBadRequest) + } + seedReq, err := bindModelAPISeedanceRequestAfterAssetRewrite(c, info) + if err != nil { + return taskError(err, "invalid_request", http.StatusBadRequest) + } + if err := validateModelAPISeedanceRequest(seedReq); err != nil { + return taskError(err, "invalid_request", http.StatusBadRequest) + } + info.UpstreamModelName = UpstreamModel + if info.ChannelMeta != nil { + info.ChannelMeta.UpstreamModelName = UpstreamModel + } + return nil +} + +func (a *TaskAdaptor) ValidateTaskPriceData(info *relaycommon.RelayInfo) *dto.TaskError { + if !validModelAPIPriceData(info) { + return taskError(fmt.Errorf("model price must be a positive finite fixed price"), "model_price_error", http.StatusBadRequest) + } + return nil +} + +func (a *TaskAdaptor) EstimateBilling(c *gin.Context, info *relaycommon.RelayInfo) map[string]float64 { + if c == nil || !validModelAPIPriceData(info) { + return nil + } + seedReq, err := taskcommon.GetSeedanceRequest(c) + if err != nil || seedReq == nil { + return nil + } + + duration := 5 + if seedReq.Duration != nil { + duration = *seedReq.Duration + } + resolution := seedReq.Resolution + if resolution == "" { + resolution = "720p" + } + + estimatedUSD, ok := modelAPIEstimatedUSD(resolution, duration, len(seedReq.Videos()) > 0) + if !ok { + return nil + } + return modelAPIBillingUnits(info.PriceData.ModelPrice, estimatedUSD) +} + +func (a *TaskAdaptor) BuildRequestURL(_ *relaycommon.RelayInfo) (string, error) { + return a.baseURL + "/v1/tasks", nil +} + +func (a *TaskAdaptor) BuildRequestHeader(_ *gin.Context, req *http.Request, _ *relaycommon.RelayInfo) error { + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + req.Header.Set("Authorization", "Bearer "+a.apiKey) + return nil +} + +func (a *TaskAdaptor) BuildRequestBody(c *gin.Context, _ *relaycommon.RelayInfo) (io.Reader, error) { + seedReq, err := taskcommon.GetSeedanceRequest(c) + if err != nil { + return nil, err + } + rewriteMap, _ := common.GetContextKeyType[map[string]string](c, constant.ContextKeyAssetRewriteMap) + if err := rewriteModelAPIAssetReferences(seedReq, rewriteMap); err != nil { + return nil, err + } + if err := validateModelAPISeedanceRequest(seedReq); err != nil { + return nil, err + } + taskcommon.SetSeedanceRequest(c, seedReq) + body := buildModelAPICreateRequest(seedReq) + data, err := common.MarshalNoHTMLEscape(body) + if err != nil { + return nil, err + } + return bytes.NewReader(data), nil +} + +func (a *TaskAdaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, requestBody io.Reader) (*http.Response, error) { + if info != nil { + proxy := strings.TrimSpace(info.ChannelSetting.Proxy) + if proxy != "" { + return nil, errModelAPISeedanceProxyUnsupported() + } + if info.ChannelSetting.Proxy != "" { + scopedInfo := *info + if info.ChannelMeta != nil { + scopedMeta := *info.ChannelMeta + scopedMeta.ChannelSetting.Proxy = "" + scopedInfo.ChannelMeta = &scopedMeta + } + return channel.DoTaskApiRequest(a, c, &scopedInfo, requestBody) + } + } + return channel.DoTaskApiRequest(a, c, info, requestBody) +} + +func (a *TaskAdaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (taskID string, taskData []byte, taskErr *dto.TaskError) { + defer func() { _ = resp.Body.Close() }() + responseBody, err := io.ReadAll(io.LimitReader(resp.Body, maxModelAPISubmitResponseBytes+1)) + if err != nil { + return "", nil, taskError(fmt.Errorf("failed to read upstream response"), "read_response_body_failed", http.StatusInternalServerError) + } + if len(responseBody) > maxModelAPISubmitResponseBytes { + return "", nil, taskError(fmt.Errorf("invalid upstream response"), "invalid_response", http.StatusBadGateway) + } + + submit, estimatedUSD, err := parseModelAPISubmitResponse(responseBody) + if err != nil { + return "", nil, taskError(fmt.Errorf("invalid upstream response"), "invalid_response", http.StatusBadGateway) + } + if submit.Status == modelAPIStatusFailed { + return "", nil, taskError(fmt.Errorf("%s", modelAPIFailureReason()), "upstream_error", modelAPISubmitFailureStatusCode(submit.Error)) + } + if strings.TrimSpace(submit.TaskID) == "" { + return "", nil, taskError(fmt.Errorf("upstream response missing task_id"), "invalid_response", http.StatusBadGateway) + } + + ov := dto.NewOpenAIVideo() + if info != nil { + ov.ID = info.PublicTaskID + ov.TaskID = info.PublicTaskID + ov.Model = info.OriginModelName + } + ov.CreatedAt = time.Now().Unix() + c.JSON(http.StatusOK, ov) + snapshot := modelAPISubmitTaskData{Status: submit.Status} + if estimatedUSD != nil { + snapshot.EstimatedUSD = estimatedUSD + } + taskData, err = common.Marshal(snapshot) + if err != nil { + return "", nil, taskError(fmt.Errorf("failed to persist submit status"), "invalid_response", http.StatusBadGateway) + } + return submit.TaskID, taskData, nil +} + +func (a *TaskAdaptor) AdjustBillingOnSubmit(info *relaycommon.RelayInfo, taskData []byte) map[string]float64 { + if !validModelAPIPriceData(info) { + return nil + } + var snapshot modelAPISubmitTaskData + if err := common.Unmarshal(taskData, &snapshot); err != nil { + return nil + } + if snapshot.EstimatedUSD == nil || !validPositiveFinite(*snapshot.EstimatedUSD) { + return nil + } + return modelAPIBillingUnits(info.PriceData.ModelPrice, *snapshot.EstimatedUSD) +} + +func (a *TaskAdaptor) GetModelList() []string { + return ModelList +} + +func (a *TaskAdaptor) GetChannelName() string { + return ChannelName +} + +func (a *TaskAdaptor) FetchTask(baseURL string, key string, body map[string]any, proxy string) (*http.Response, error) { + return a.FetchTaskWithContext(context.Background(), baseURL, key, body, proxy) +} + +func (a *TaskAdaptor) FetchTaskWithContext(ctx context.Context, baseURL string, key string, body map[string]any, proxy string) (*http.Response, error) { + if ctx == nil { + ctx = context.Background() + } + proxy = strings.TrimSpace(proxy) + if proxy != "" { + return nil, errModelAPISeedanceProxyUnsupported() + } + taskID, ok := body["task_id"].(string) + if !ok || strings.TrimSpace(taskID) == "" { + return nil, fmt.Errorf("invalid task_id") + } + baseURL = strings.TrimRight(strings.TrimSpace(baseURL), "/") + if baseURL == "" { + baseURL = constant.ChannelBaseURLs[constant.ChannelTypeModelAPISeedance] + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, baseURL+"/v1/tasks/"+url.PathEscape(taskID), nil) + if err != nil { + return nil, err + } + req.Header.Set("Accept", "application/json") + req.Header.Set("Authorization", "Bearer "+key) + client, err := service.GetHttpClientWithProxy(proxy) + if err != nil { + return nil, fmt.Errorf("new proxy http client failed: %w", err) + } + return client.Do(req) +} + +func errModelAPISeedanceProxyUnsupported() error { + return fmt.Errorf("this channel type does not support proxy") +} + +func (a *TaskAdaptor) ParseTaskResult(respBody []byte) (*relaycommon.TaskInfo, error) { + var result modelAPITaskResponse + if err := common.Unmarshal(respBody, &result); err != nil { + return nil, fmt.Errorf("invalid task result response") + } + info := &relaycommon.TaskInfo{Code: 0, TaskID: result.TaskID} + switch result.Status { + case modelAPIStatusPending: + info.Status = model.TaskStatusQueued + info.Progress = taskcommon.ProgressQueued + case modelAPIStatusPolling, modelAPIStatusRunning: + info.Status = model.TaskStatusInProgress + info.Progress = taskcommon.ProgressInProgress + case modelAPIStatusSucceeded: + videoURL := firstModelAPIVideoURL(result.Result.Assets) + if videoURL == "" { + return nil, fmt.Errorf("succeeded task is missing video asset") + } + info.Status = model.TaskStatusSuccess + info.Progress = taskcommon.ProgressComplete + info.Url = videoURL + case modelAPIStatusFailed: + info.Status = model.TaskStatusFailure + info.Progress = taskcommon.ProgressComplete + info.Reason = modelAPIFailureReason() + default: + info.Status = model.TaskStatusInProgress + info.Progress = taskcommon.ProgressInProgress + } + return info, nil +} + +func (a *TaskAdaptor) ConvertToOpenAIVideo(originTask *model.Task) ([]byte, error) { + ov := dto.NewOpenAIVideo() + ov.ID = originTask.TaskID + ov.TaskID = originTask.TaskID + ov.Status = originTask.Status.ToVideoStatus() + ov.SetProgressStr(originTask.Progress) + ov.CreatedAt = originTask.CreatedAt + ov.CompletedAt = originTask.UpdatedAt + ov.Model = originTask.Properties.OriginModelName + if originTask.Status == model.TaskStatusSuccess { + ov.SetMetadata("url", originTask.GetResultURL()) + } + if originTask.Status == model.TaskStatusFailure { + ov.Error = &dto.OpenAIVideoError{ + Message: modelAPIFailureReason(), + } + } + return common.Marshal(ov) +} + +func taskError(err error, code string, statusCode int) *dto.TaskError { + message := "" + if err != nil { + message = err.Error() + } + return &dto.TaskError{ + Code: code, + Message: message, + StatusCode: statusCode, + LocalError: true, + Error: err, + } +} + +type modelAPIInputItem struct { + Role string `json:"role"` + Content string `json:"content,omitempty"` + URL string `json:"url,omitempty"` +} + +type modelAPIInput struct { + Text []modelAPIInputItem `json:"text,omitempty"` + Image []modelAPIInputItem `json:"image,omitempty"` + Video []modelAPIInputItem `json:"video,omitempty"` + Audio []modelAPIInputItem `json:"audio,omitempty"` +} + +type modelAPIParams struct { + Duration *int `json:"duration,omitempty"` + Resolution string `json:"resolution,omitempty"` + AspectRatio string `json:"aspect_ratio,omitempty"` + Seed *int `json:"seed,omitempty"` + GenerateAudio *bool `json:"generate_audio,omitempty"` + Watermark *bool `json:"watermark,omitempty"` + ReturnLastFrame *bool `json:"return_last_frame,omitempty"` +} + +type modelAPICreateRequest struct { + Model string `json:"model"` + Input modelAPIInput `json:"input"` + Params *modelAPIParams `json:"params,omitempty"` +} + +type modelAPIError struct { + Code string `json:"code"` + Message string `json:"message"` +} + +type modelAPIAsset struct { + Type string `json:"type"` + URL string `json:"url"` +} + +type modelAPIResult struct { + Assets []modelAPIAsset `json:"assets"` +} + +type modelAPISubmitResponse struct { + TaskID string `json:"task_id"` + Status string `json:"status"` + Usage json.RawMessage `json:"usage"` + Error modelAPIError `json:"error"` +} + +type modelAPITaskResponse struct { + TaskID string `json:"task_id"` + Status string `json:"status"` + Result modelAPIResult `json:"result"` + Error modelAPIError `json:"error"` +} + +type modelAPISubmitTaskData struct { + Status string `json:"status,omitempty"` + EstimatedUSD *float64 `json:"estimated_usd,omitempty"` +} + +const ( + modelAPIStatusPending = "pending" + modelAPIStatusPolling = "polling" + modelAPIStatusRunning = "running" + modelAPIStatusSucceeded = "succeeded" + modelAPIStatusFailed = "failed" + + modelAPIGenericFailureReason = "task failed at upstream provider" +) + +func buildModelAPICreateRequest(seedReq *dto.SeedanceVideoRequest) modelAPICreateRequest { + body := modelAPICreateRequest{ + Model: UpstreamModel, + Input: modelAPIInput{ + Text: []modelAPIInputItem{}, + Image: []modelAPIInputItem{}, + Video: []modelAPIInputItem{}, + Audio: []modelAPIInputItem{}, + }, + } + if prompt := strings.TrimSpace(seedReq.PromptText()); prompt != "" { + body.Input.Text = append(body.Input.Text, modelAPIInputItem{Role: "prompt", Content: prompt}) + } + for _, m := range seedReq.Images() { + body.Input.Image = append(body.Input.Image, modelAPIInputItem{Role: modelAPIImageRole(m.Role), URL: m.URL}) + } + for _, m := range seedReq.Videos() { + body.Input.Video = append(body.Input.Video, modelAPIInputItem{Role: modelAPIReferenceRole, URL: m.URL}) + } + for _, m := range seedReq.Audios() { + body.Input.Audio = append(body.Input.Audio, modelAPIInputItem{Role: modelAPIReferenceRole, URL: m.URL}) + } + params := modelAPIParams{ + Duration: seedReq.Duration, + Resolution: seedReq.Resolution, + AspectRatio: seedReq.Ratio, + Seed: seedReq.Seed, + GenerateAudio: seedReq.GenerateAudio, + Watermark: seedReq.Watermark, + ReturnLastFrame: seedReq.ReturnLastFrame, + } + if params.hasAny() { + body.Params = ¶ms + } + return body +} + +func modelAPIFailureReason() string { + return modelAPIGenericFailureReason +} + +func modelAPISubmitFailureStatusCode(upstreamErr modelAPIError) int { + if strings.EqualFold(strings.TrimSpace(upstreamErr.Code), "rate_limit_exceeded") { + return http.StatusTooManyRequests + } + normalizedMessage := strings.ToLower(strings.Join(strings.Fields(upstreamErr.Message), " ")) + if normalizedMessage == "selected model is at capacity" || strings.Contains(normalizedMessage, "selected model is at capacity") { + return http.StatusTooManyRequests + } + return http.StatusBadGateway +} + +const modelAPIReferenceRole = "reference" + +func modelAPIImageRole(role string) string { + switch role { + case dto.SeedanceRoleFirstFrame, dto.SeedanceRoleLastFrame: + return role + default: + return modelAPIReferenceRole + } +} + +func (p modelAPIParams) hasAny() bool { + return p.Duration != nil || + p.Resolution != "" || + p.AspectRatio != "" || + p.Seed != nil || + p.GenerateAudio != nil || + p.Watermark != nil || + p.ReturnLastFrame != nil +} + +func parseModelAPISubmitResponse(data []byte) (modelAPISubmitResponse, *float64, error) { + var submit modelAPISubmitResponse + if err := common.Unmarshal(data, &submit); err != nil { + return submit, nil, err + } + estimatedUSD := parseModelAPIEstimatedUSD(submit.Usage) + return submit, estimatedUSD, nil +} + +func parseModelAPIEstimatedUSD(usage json.RawMessage) *float64 { + if len(bytes.TrimSpace(usage)) == 0 || bytes.Equal(bytes.TrimSpace(usage), []byte("null")) { + return nil + } + var parsed struct { + EstimatedUSD *float64 `json:"estimated_usd"` + } + if err := common.Unmarshal(usage, &parsed); err != nil { + return nil + } + if parsed.EstimatedUSD == nil || !validPositiveFinite(*parsed.EstimatedUSD) { + return nil + } + return parsed.EstimatedUSD +} + +func validModelAPIPriceData(info *relaycommon.RelayInfo) bool { + return info != nil && info.PriceData.UsePrice && validPositiveFinite(info.PriceData.ModelPrice) +} + +func validPositiveFinite(value float64) bool { + return value > 0 && !math.IsNaN(value) && !math.IsInf(value, 0) +} + +func modelAPIEstimatedUSD(resolution string, duration int, hasVideo bool) (float64, bool) { + if hasVideo { + switch resolution { + case "480p": + return 0.084 * 30, true + case "720p": + return 0.188 * 30, true + default: + return 0, false + } + } + switch resolution { + case "480p": + return 0.140 * float64(duration), true + case "720p": + return 0.314 * float64(duration), true + default: + return 0, false + } +} + +func modelAPIBillingUnits(modelPrice, estimatedUSD float64) map[string]float64 { + if !validPositiveFinite(modelPrice) || !validPositiveFinite(estimatedUSD) { + return nil + } + units := estimatedUSD / modelPrice + if !validPositiveFinite(units) { + return nil + } + return map[string]float64{modelAPIBillingUnitsKey: units} +} + +func bindModelAPISeedanceRequestAfterAssetRewrite(c *gin.Context, info *relaycommon.RelayInfo) (*dto.SeedanceVideoRequest, error) { + originalReq, err := taskcommon.BindSeedanceRequest(c, info, constant.TaskActionGenerate) + if err != nil { + return nil, err + } + data, err := common.Marshal(originalReq) + if err != nil { + return nil, err + } + var req dto.SeedanceVideoRequest + if err := common.Unmarshal(data, &req); err != nil { + return nil, err + } + rewriteMap, _ := common.GetContextKeyType[map[string]string](c, constant.ContextKeyAssetRewriteMap) + if err := rewriteModelAPIAssetReferences(&req, rewriteMap); err != nil { + return nil, err + } + if err := req.Validate(); err != nil { + return nil, err + } + + taskcommon.SetSeedanceRequest(c, &req) + return &req, nil +} + +func rewriteModelAPIAssetReferences(seedReq *dto.SeedanceVideoRequest, rewriteMap map[string]string) error { + if seedReq == nil { + return nil + } + for index := range seedReq.Content { + item := &seedReq.Content[index] + for _, media := range []*dto.SeedanceURLObject{item.ImageURL, item.VideoURL, item.AudioURL} { + if media == nil { + continue + } + rawURL := media.URL + if !service.IsStrictBytePlusAssetURI(rawURL) { + if strings.HasPrefix(strings.ToLower(strings.TrimSpace(rawURL)), "asset://ast_") { + return fmt.Errorf("invalid asset reference") + } + continue + } + upstreamURL, ok := rewriteMap[rawURL] + if !ok || validateModelAPIAssetRewriteURL(upstreamURL) != nil { + return fmt.Errorf("invalid asset reference") + } + media.URL = upstreamURL + } + } + return nil +} + +var supportedModelAPIResolutions = map[string]struct{}{ + "480p": {}, + "720p": {}, +} + +var supportedModelAPIAspectRatios = map[string]struct{}{ + "16:9": {}, + "4:3": {}, + "1:1": {}, + "3:4": {}, + "9:16": {}, + "adaptive": {}, +} + +func validateModelAPISeedanceRequest(seedReq *dto.SeedanceVideoRequest) error { + if seedReq.Duration != nil && (*seedReq.Duration < 4 || *seedReq.Duration > 30) { + return fmt.Errorf("duration must be between 4 and 30") + } + if seedReq.Resolution != "" { + if _, ok := supportedModelAPIResolutions[seedReq.Resolution]; !ok { + return fmt.Errorf("unsupported resolution") + } + } + if seedReq.Ratio != "" { + if _, ok := supportedModelAPIAspectRatios[seedReq.Ratio]; !ok { + return fmt.Errorf("unsupported aspect_ratio") + } + } + + imageCount, videoCount, audioCount := 0, 0, 0 + firstFrameCount, lastFrameCount := 0, 0 + for _, m := range seedReq.Images() { + if err := validateModelAPIMediaURL(m.URL); err != nil { + return err + } + imageCount++ + switch m.Role { + case "", dto.SeedanceRoleReferenceImage: + case dto.SeedanceRoleFirstFrame: + firstFrameCount++ + case dto.SeedanceRoleLastFrame: + lastFrameCount++ + default: + return fmt.Errorf("unsupported image role") + } + } + for _, m := range seedReq.Videos() { + if err := validateModelAPIMediaURL(m.URL); err != nil { + return err + } + videoCount++ + if m.Role != "" && m.Role != dto.SeedanceRoleReferenceVideo { + return fmt.Errorf("unsupported video role") + } + } + for _, m := range seedReq.Audios() { + if err := validateModelAPIMediaURL(m.URL); err != nil { + return err + } + audioCount++ + if m.Role != "" && m.Role != dto.SeedanceRoleReferenceAudio { + return fmt.Errorf("unsupported audio role") + } + } + + if imageCount > 30 { + return fmt.Errorf("image references exceed limit") + } + if videoCount > 10 { + return fmt.Errorf("video references exceed limit") + } + if audioCount > 10 { + return fmt.Errorf("audio references exceed limit") + } + if imageCount+videoCount+audioCount > 50 { + return fmt.Errorf("media references exceed limit") + } + if firstFrameCount > 1 { + return fmt.Errorf("first_frame supports at most one image") + } + if lastFrameCount > 1 { + return fmt.Errorf("last_frame supports at most one image") + } + if lastFrameCount > 0 && firstFrameCount == 0 { + return fmt.Errorf("last_frame requires first_frame") + } + return nil +} + +func validateModelAPIMediaURL(raw string) error { + if err := validateModelAPIHTTPSURL(raw); err != nil { + return fmt.Errorf("media url is not allowed") + } + if err := taskcommon.ValidateRemoteMediaURL(raw); err != nil { + return fmt.Errorf("media url is not allowed") + } + return nil +} + +func validateModelAPIAssetRewriteURL(raw string) error { + if err := validateModelAPIHTTPSURL(raw); err != nil { + return err + } + return validateModelAPIMediaURL(raw) +} + +func validateModelAPIHTTPSURL(raw string) error { + if raw == "" || raw != strings.TrimSpace(raw) { + return fmt.Errorf("media url must be https") + } + parsed, err := url.Parse(raw) + if err != nil || parsed.Scheme != "https" || parsed.Host == "" { + return fmt.Errorf("media url must be https") + } + return nil +} + +func firstModelAPIVideoURL(assets []modelAPIAsset) string { + for _, asset := range assets { + if asset.Type == "video" && strings.TrimSpace(asset.URL) != "" { + return asset.URL + } + } + return "" +} diff --git a/relay/channel/task/modelapiseedance/adaptor_test.go b/relay/channel/task/modelapiseedance/adaptor_test.go new file mode 100644 index 00000000000..d53f17b6317 --- /dev/null +++ b/relay/channel/task/modelapiseedance/adaptor_test.go @@ -0,0 +1,974 @@ +package modelapiseedance + +import ( + "context" + "errors" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/constant" + "github.com/QuantumNous/new-api/dto" + "github.com/QuantumNous/new-api/model" + "github.com/QuantumNous/new-api/relay/channel" + relaycommon "github.com/QuantumNous/new-api/relay/common" + "github.com/QuantumNous/new-api/service" + "github.com/QuantumNous/new-api/setting/system_setting" + + "github.com/gin-gonic/gin" +) + +var ( + _ channel.TaskAdaptor = (*TaskAdaptor)(nil) + _ channel.OpenAIVideoConverter = (*TaskAdaptor)(nil) +) + +func TestModelAPISeedanceAdaptorIdentity(t *testing.T) { + adaptor := &TaskAdaptor{} + if got := adaptor.GetChannelName(); got != "modelapi-seedance" { + t.Fatalf("GetChannelName() = %q, want modelapi-seedance", got) + } + models := adaptor.GetModelList() + if len(models) != 1 || models[0] != "doubao-seedance-2-5-260628" { + t.Fatalf("GetModelList() = %v, want [doubao-seedance-2-5-260628]", models) + } +} + +func modelAPIPtrInt(v int) *int { return &v } +func modelAPIPtrBool(v bool) *bool { return &v } + +func newModelAPITestContext(body string) (*gin.Context, *httptest.ResponseRecorder) { + gin.SetMode(gin.TestMode) + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/videos", strings.NewReader(body)) + c.Request.Header.Set("Content-Type", "application/json") + return c, w +} + +func newModelAPIRelayInfo(baseURL, key string) *relaycommon.RelayInfo { + return &relaycommon.RelayInfo{ + OriginModelName: "client-seedance", + ChannelMeta: &relaycommon.ChannelMeta{ + ChannelType: constant.ChannelTypeModelAPISeedance, + ChannelBaseUrl: baseURL, + ApiKey: key, + UpstreamModelName: "client-configured-model", + }, + TaskRelayInfo: &relaycommon.TaskRelayInfo{PublicTaskID: "task_public"}, + } +} + +func TestBuildModelAPICreateRequestMapsTextMediaRolesAndVideoAssetSelection(t *testing.T) { + seedReq := &dto.SeedanceVideoRequest{ + Model: "client-model", + Content: []dto.SeedanceContentItem{ + {Type: dto.SeedanceContentText, Text: "make it cinematic"}, + {Type: dto.SeedanceContentImage, ImageURL: &dto.SeedanceURLObject{URL: "https://cdn.example/ref.png"}}, + {Type: dto.SeedanceContentImage, ImageURL: &dto.SeedanceURLObject{URL: "https://cdn.example/first.png"}, Role: dto.SeedanceRoleFirstFrame}, + {Type: dto.SeedanceContentImage, ImageURL: &dto.SeedanceURLObject{URL: "https://cdn.example/last.png"}, Role: dto.SeedanceRoleLastFrame}, + {Type: dto.SeedanceContentVideo, VideoURL: &dto.SeedanceURLObject{URL: "https://cdn.example/ref.mp4"}}, + {Type: dto.SeedanceContentAudio, AudioURL: &dto.SeedanceURLObject{URL: "https://cdn.example/ref.mp3"}}, + }, + Ratio: "16:9", + Resolution: "720p", + Duration: modelAPIPtrInt(5), + Seed: modelAPIPtrInt(42), + GenerateAudio: modelAPIPtrBool(true), + Watermark: modelAPIPtrBool(false), + ReturnLastFrame: modelAPIPtrBool(false), + } + + body := buildModelAPICreateRequest(seedReq) + if body.Model != UpstreamModel { + t.Fatalf("model = %q, want fixed upstream model %q", body.Model, UpstreamModel) + } + if len(body.Input.Text) != 1 || len(body.Input.Image) != 3 || len(body.Input.Video) != 1 || len(body.Input.Audio) != 1 { + t.Fatalf("input groups not mapped: %+v", body.Input) + } + if body.Input.Text[0].Role != "prompt" || body.Input.Text[0].Content != "make it cinematic" { + t.Fatalf("text input = %+v", body.Input.Text[0]) + } + wantRoles := []string{"reference", "first_frame", "last_frame", "reference", "reference"} + gotRoles := []string{ + body.Input.Image[0].Role, + body.Input.Image[1].Role, + body.Input.Image[2].Role, + body.Input.Video[0].Role, + body.Input.Audio[0].Role, + } + for i, want := range wantRoles { + if got := gotRoles[i]; got != want { + t.Fatalf("input role[%d] = %q, want %q", i, got, want) + } + } + if body.Params == nil || body.Params.AspectRatio != "16:9" || body.Params.Resolution != "720p" { + t.Fatalf("params not mapped: %+v", body.Params) + } + + info, err := (&TaskAdaptor{}).ParseTaskResult([]byte(`{ + "task_id":"upstream", + "status":"succeeded", + "result":{"assets":[ + {"type":"thumbnail","url":"https://cdn.example/thumb.jpg"}, + {"type":"video","url":"https://cdn.example/final.mp4"} + ]} + }`)) + if err != nil { + t.Fatalf("ParseTaskResult error: %v", err) + } + if info.Url != "https://cdn.example/final.mp4" { + t.Fatalf("selected url = %q, want non-first video asset", info.Url) + } +} + +func TestBuildRequestBodyUsesModelAPIGroupedInputWireShape(t *testing.T) { + c, _ := newModelAPITestContext(`{ + "model":"client-model", + "content":[ + {"type":"text","text":"make it cinematic"}, + {"type":"image_url","image_url":{"url":"https://example.com/ref.png"},"role":"reference_image"}, + {"type":"image_url","image_url":{"url":"https://example.com/first.png"},"role":"first_frame"}, + {"type":"image_url","image_url":{"url":"https://example.com/last.png"},"role":"last_frame"}, + {"type":"video_url","video_url":{"url":"https://example.com/ref.mp4"}}, + {"type":"audio_url","audio_url":{"url":"https://example.com/ref.mp3"}} + ] + }`) + reader, err := (&TaskAdaptor{}).BuildRequestBody(c, newModelAPIRelayInfo("", "")) + if err != nil { + t.Fatalf("BuildRequestBody error: %v", err) + } + raw, err := io.ReadAll(reader) + if err != nil { + t.Fatalf("read BuildRequestBody: %v", err) + } + + var wire map[string]any + if err := common.Unmarshal(raw, &wire); err != nil { + t.Fatalf("unmarshal wire body: %v", err) + } + input, ok := wire["input"].(map[string]any) + if !ok { + t.Fatalf("input wire type = %T, want object: %s", wire["input"], raw) + } + if _, flattened := wire["input"].([]any); flattened { + t.Fatalf("input must not be a flat array: %s", raw) + } + assertModelAPIWireItems(t, input, "text", []map[string]string{ + {"role": "prompt", "content": "make it cinematic"}, + }) + assertModelAPIWireItems(t, input, "image", []map[string]string{ + {"role": "reference", "url": "https://example.com/ref.png"}, + {"role": "first_frame", "url": "https://example.com/first.png"}, + {"role": "last_frame", "url": "https://example.com/last.png"}, + }) + assertModelAPIWireItems(t, input, "video", []map[string]string{ + {"role": "reference", "url": "https://example.com/ref.mp4"}, + }) + assertModelAPIWireItems(t, input, "audio", []map[string]string{ + {"role": "reference", "url": "https://example.com/ref.mp3"}, + }) +} + +func TestBuildRequestBodyOmitsEmptyModelAPIInputGroups(t *testing.T) { + c, _ := newModelAPITestContext(`{ + "model":"client-model", + "content":[{"type":"text","text":"make it cinematic"}] + }`) + reader, err := (&TaskAdaptor{}).BuildRequestBody(c, newModelAPIRelayInfo("", "")) + if err != nil { + t.Fatalf("BuildRequestBody error: %v", err) + } + raw, err := io.ReadAll(reader) + if err != nil { + t.Fatalf("read BuildRequestBody: %v", err) + } + + var wire map[string]any + if err := common.Unmarshal(raw, &wire); err != nil { + t.Fatalf("unmarshal wire body: %v", err) + } + input, ok := wire["input"].(map[string]any) + if !ok { + t.Fatalf("input wire type = %T, want object: %s", wire["input"], raw) + } + if len(input) != 1 { + t.Fatalf("input keys = %v, want only text: %s", input, raw) + } + assertModelAPIWireItems(t, input, "text", []map[string]string{ + {"role": "prompt", "content": "make it cinematic"}, + }) + for _, emptyGroup := range []string{"image", "video", "audio"} { + if _, ok := input[emptyGroup]; ok { + t.Fatalf("input.%s should be omitted for text-only request: %s", emptyGroup, raw) + } + } +} + +func TestBuildRequestBodyPreservesExplicitZeroFalseAndOmitsAbsentParams(t *testing.T) { + c, _ := newModelAPITestContext(`{ + "model":"client-model", + "content":[{"type":"text","text":"x"},{"type":"image_url","image_url":{"url":"https://example.com/i.png?a=1&b=2"}}], + "seed":0, + "generate_audio":false, + "watermark":false, + "return_last_frame":false + }`) + reader, err := (&TaskAdaptor{}).BuildRequestBody(c, newModelAPIRelayInfo("", "")) + if err != nil { + t.Fatalf("BuildRequestBody error: %v", err) + } + endToEndRaw, err := io.ReadAll(reader) + if err != nil { + t.Fatalf("read BuildRequestBody: %v", err) + } + if !strings.Contains(string(endToEndRaw), "a=1&b=2") { + t.Fatalf("BuildRequestBody escaped URL query: %s", endToEndRaw) + } + + seedReq := &dto.SeedanceVideoRequest{ + Content: []dto.SeedanceContentItem{{Type: dto.SeedanceContentText, Text: "x"}}, + Seed: modelAPIPtrInt(0), + GenerateAudio: modelAPIPtrBool(false), + Watermark: modelAPIPtrBool(false), + ReturnLastFrame: modelAPIPtrBool(false), + } + body := buildModelAPICreateRequest(seedReq) + raw, err := common.MarshalNoHTMLEscape(body) + if err != nil { + t.Fatalf("marshal body: %v", err) + } + text := string(raw) + for _, want := range []string{`"seed":0`, `"generate_audio":false`, `"watermark":false`, `"return_last_frame":false`} { + if !strings.Contains(text, want) { + t.Fatalf("body missing %s: %s", want, text) + } + } + for _, omitted := range []string{"duration", "resolution", "aspect_ratio"} { + if strings.Contains(text, omitted) { + t.Fatalf("body should omit %s when absent: %s", omitted, text) + } + } +} + +func assertModelAPIWireItems(t *testing.T, input map[string]any, key string, want []map[string]string) { + t.Helper() + items, ok := input[key].([]any) + if !ok { + t.Fatalf("input.%s wire type = %T, want array", key, input[key]) + } + if len(items) != len(want) { + t.Fatalf("input.%s length = %d, want %d: %+v", key, len(items), len(want), items) + } + for i, item := range items { + got, ok := item.(map[string]any) + if !ok { + t.Fatalf("input.%s[%d] wire type = %T, want object", key, i, item) + } + for field, wantValue := range want[i] { + if gotValue, _ := got[field].(string); gotValue != wantValue { + t.Fatalf("input.%s[%d].%s = %q, want %q", key, i, field, gotValue, wantValue) + } + } + } +} + +func TestValidateModelAPISeedanceValues(t *testing.T) { + valid := dto.SeedanceVideoRequest{ + Content: []dto.SeedanceContentItem{ + {Type: dto.SeedanceContentText, Text: "x"}, + {Type: dto.SeedanceContentImage, ImageURL: &dto.SeedanceURLObject{URL: "https://example.com/ref.png"}}, + {Type: dto.SeedanceContentVideo, VideoURL: &dto.SeedanceURLObject{URL: "https://example.com/ref.mp4"}}, + {Type: dto.SeedanceContentAudio, AudioURL: &dto.SeedanceURLObject{URL: "https://example.com/ref.mp3"}}, + }, + Duration: modelAPIPtrInt(4), + Resolution: "480p", + Ratio: "adaptive", + } + if err := validateModelAPISeedanceRequest(&valid); err != nil { + t.Fatalf("valid request rejected: %v", err) + } + + tests := []struct { + name string + req dto.SeedanceVideoRequest + }{ + {name: "duration low", req: dto.SeedanceVideoRequest{Duration: modelAPIPtrInt(3)}}, + {name: "duration high", req: dto.SeedanceVideoRequest{Duration: modelAPIPtrInt(31)}}, + {name: "resolution", req: dto.SeedanceVideoRequest{Resolution: "1080p"}}, + {name: "aspect", req: dto.SeedanceVideoRequest{Ratio: "3:2"}}, + {name: "image role", req: dto.SeedanceVideoRequest{Content: []dto.SeedanceContentItem{{Type: dto.SeedanceContentImage, ImageURL: &dto.SeedanceURLObject{URL: "https://example.com/i.png"}, Role: "cover"}}}}, + {name: "video role", req: dto.SeedanceVideoRequest{Content: []dto.SeedanceContentItem{{Type: dto.SeedanceContentVideo, VideoURL: &dto.SeedanceURLObject{URL: "https://example.com/v.mp4"}, Role: "first_frame"}}}}, + {name: "audio role", req: dto.SeedanceVideoRequest{Content: []dto.SeedanceContentItem{{Type: dto.SeedanceContentAudio, AudioURL: &dto.SeedanceURLObject{URL: "https://example.com/a.mp3"}, Role: "narration"}}}}, + {name: "last without first", req: dto.SeedanceVideoRequest{Content: []dto.SeedanceContentItem{{Type: dto.SeedanceContentImage, ImageURL: &dto.SeedanceURLObject{URL: "https://example.com/last.png"}, Role: dto.SeedanceRoleLastFrame}}}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := validateModelAPISeedanceRequest(&tt.req); err == nil { + t.Fatal("expected validation error") + } + }) + } + + countReq := dto.SeedanceVideoRequest{} + for i := 0; i < 31; i++ { + countReq.Content = append(countReq.Content, dto.SeedanceContentItem{Type: dto.SeedanceContentImage, ImageURL: &dto.SeedanceURLObject{URL: "https://example.com/i.png"}}) + } + if err := validateModelAPISeedanceRequest(&countReq); err == nil { + t.Fatal("expected image count error") + } + + videoCountReq := dto.SeedanceVideoRequest{} + for i := 0; i < 11; i++ { + videoCountReq.Content = append(videoCountReq.Content, dto.SeedanceContentItem{Type: dto.SeedanceContentVideo, VideoURL: &dto.SeedanceURLObject{URL: "https://example.com/v.mp4"}}) + } + if err := validateModelAPISeedanceRequest(&videoCountReq); err == nil { + t.Fatal("expected video count error") + } + + audioCountReq := dto.SeedanceVideoRequest{} + for i := 0; i < 11; i++ { + audioCountReq.Content = append(audioCountReq.Content, dto.SeedanceContentItem{Type: dto.SeedanceContentAudio, AudioURL: &dto.SeedanceURLObject{URL: "https://example.com/a.mp3"}}) + } + if err := validateModelAPISeedanceRequest(&audioCountReq); err == nil { + t.Fatal("expected audio count error") + } + + totalCountReq := dto.SeedanceVideoRequest{} + for i := 0; i < 30; i++ { + totalCountReq.Content = append(totalCountReq.Content, dto.SeedanceContentItem{Type: dto.SeedanceContentImage, ImageURL: &dto.SeedanceURLObject{URL: "https://example.com/i.png"}}) + } + for i := 0; i < 10; i++ { + totalCountReq.Content = append(totalCountReq.Content, dto.SeedanceContentItem{Type: dto.SeedanceContentVideo, VideoURL: &dto.SeedanceURLObject{URL: "https://example.com/v.mp4"}}) + } + for i := 0; i < 11; i++ { + totalCountReq.Content = append(totalCountReq.Content, dto.SeedanceContentItem{Type: dto.SeedanceContentAudio, AudioURL: &dto.SeedanceURLObject{URL: "https://example.com/a.mp3"}}) + } + if err := validateModelAPISeedanceRequest(&totalCountReq); err == nil { + t.Fatal("expected total media count error") + } + + firstFrameReq := dto.SeedanceVideoRequest{Content: []dto.SeedanceContentItem{ + {Type: dto.SeedanceContentImage, ImageURL: &dto.SeedanceURLObject{URL: "https://example.com/first-a.png"}, Role: dto.SeedanceRoleFirstFrame}, + {Type: dto.SeedanceContentImage, ImageURL: &dto.SeedanceURLObject{URL: "https://example.com/first-b.png"}, Role: dto.SeedanceRoleFirstFrame}, + }} + if err := validateModelAPISeedanceRequest(&firstFrameReq); err == nil { + t.Fatal("expected first_frame max-one error") + } + + lastFrameReq := dto.SeedanceVideoRequest{Content: []dto.SeedanceContentItem{ + {Type: dto.SeedanceContentImage, ImageURL: &dto.SeedanceURLObject{URL: "https://example.com/first.png"}, Role: dto.SeedanceRoleFirstFrame}, + {Type: dto.SeedanceContentImage, ImageURL: &dto.SeedanceURLObject{URL: "https://example.com/last-a.png"}, Role: dto.SeedanceRoleLastFrame}, + {Type: dto.SeedanceContentImage, ImageURL: &dto.SeedanceURLObject{URL: "https://example.com/last-b.png"}, Role: dto.SeedanceRoleLastFrame}, + }} + if err := validateModelAPISeedanceRequest(&lastFrameReq); err == nil { + t.Fatal("expected last_frame max-one error") + } +} + +func TestValidateModelAPISeedanceRequestValidatesRemoteMediaURLs(t *testing.T) { + original := *system_setting.GetFetchSetting() + t.Cleanup(func() { *system_setting.GetFetchSetting() = original }) + system_setting.GetFetchSetting().EnableSSRFProtection = true + system_setting.GetFetchSetting().AllowPrivateIp = false + system_setting.GetFetchSetting().DomainFilterMode = false + system_setting.GetFetchSetting().IpFilterMode = false + system_setting.GetFetchSetting().AllowedPorts = []string{"80", "443"} + system_setting.GetFetchSetting().ApplyIPFilterForDomain = false + + tests := []struct { + name string + content dto.SeedanceContentItem + wantErr bool + }{ + { + name: "rejects private image url", + content: dto.SeedanceContentItem{Type: dto.SeedanceContentImage, ImageURL: &dto.SeedanceURLObject{URL: "http://127.0.0.1/private.png"}}, + wantErr: true, + }, + { + name: "rejects file image url", + content: dto.SeedanceContentItem{Type: dto.SeedanceContentImage, ImageURL: &dto.SeedanceURLObject{URL: "file:///tmp/private.png"}}, + wantErr: true, + }, + { + name: "allows public image url", + content: dto.SeedanceContentItem{Type: dto.SeedanceContentImage, ImageURL: &dto.SeedanceURLObject{URL: "https://example.com/ref.png"}}, + }, + { + name: "rejects private video url", + content: dto.SeedanceContentItem{Type: dto.SeedanceContentVideo, VideoURL: &dto.SeedanceURLObject{URL: "http://127.0.0.1/private.mp4"}}, + wantErr: true, + }, + { + name: "rejects file video url", + content: dto.SeedanceContentItem{Type: dto.SeedanceContentVideo, VideoURL: &dto.SeedanceURLObject{URL: "file:///tmp/private.mp4"}}, + wantErr: true, + }, + { + name: "allows public video url", + content: dto.SeedanceContentItem{Type: dto.SeedanceContentVideo, VideoURL: &dto.SeedanceURLObject{URL: "https://example.com/ref.mp4"}}, + }, + { + name: "rejects private audio url", + content: dto.SeedanceContentItem{Type: dto.SeedanceContentAudio, AudioURL: &dto.SeedanceURLObject{URL: "http://127.0.0.1/private.mp3"}}, + wantErr: true, + }, + { + name: "rejects file audio url", + content: dto.SeedanceContentItem{Type: dto.SeedanceContentAudio, AudioURL: &dto.SeedanceURLObject{URL: "file:///tmp/private.mp3"}}, + wantErr: true, + }, + { + name: "allows public audio url", + content: dto.SeedanceContentItem{Type: dto.SeedanceContentAudio, AudioURL: &dto.SeedanceURLObject{URL: "https://example.com/ref.mp3"}}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := dto.SeedanceVideoRequest{Content: []dto.SeedanceContentItem{tt.content}} + err := validateModelAPISeedanceRequest(&req) + if tt.wantErr { + if err == nil { + t.Fatal("expected validation error") + } + msg := err.Error() + for _, leaked := range []string{"127.0.0.1", "file:///tmp/private"} { + if strings.Contains(msg, leaked) { + t.Fatalf("validation error leaked media URL details: %q", msg) + } + } + return + } + if err != nil { + t.Fatalf("valid public URL rejected: %v", err) + } + }) + } +} + +func TestValidateRequestAndSetActionAcceptsAudioOnlyAndSetsFixedUpstreamModel(t *testing.T) { + c, _ := newModelAPITestContext(`{"model":"client-model","content":[{"type":"audio_url","audio_url":{"url":"https://example.com/a.mp3"}}]}`) + info := newModelAPIRelayInfo("", "") + a := &TaskAdaptor{} + if taskErr := a.ValidateRequestAndSetAction(c, info); taskErr != nil { + t.Fatalf("audio-only request rejected: %+v", taskErr) + } + if info.UpstreamModelName != UpstreamModel { + t.Fatalf("UpstreamModelName = %q, want %q", info.UpstreamModelName, UpstreamModel) + } + if info.Action != constant.TaskActionGenerate { + t.Fatalf("Action = %q, want generate", info.Action) + } +} + +func TestValidateRequestAfterModelMappingRestrictsPublicSubmitEntrypoints(t *testing.T) { + type postMappingValidator interface { + ValidateRequestAfterModelMapping(c *gin.Context, info *relaycommon.RelayInfo) *dto.TaskError + } + validator, ok := any(&TaskAdaptor{}).(postMappingValidator) + if !ok { + t.Fatal("TaskAdaptor must validate after model mapping") + } + + validBody := `{"model":"client-model","content":[{"type":"text","text":"hello"}]}` + for _, path := range []string{"/v1/video/generations", "/v1/generation/tasks"} { + t.Run("rejects "+path, func(t *testing.T) { + c, _ := newModelAPITestContext(validBody) + c.Request.URL.Path = path + err := validator.ValidateRequestAfterModelMapping(c, newModelAPIRelayInfo("", "")) + if err == nil { + t.Fatal("legacy submit path was accepted") + } + if err.StatusCode != http.StatusBadRequest || err.Code != "invalid_request" { + t.Fatalf("error = %+v, want invalid_request 400", err) + } + }) + } + + t.Run("rejects missing URL without panic", func(t *testing.T) { + c, _ := newModelAPITestContext(validBody) + c.Request.URL = nil + err := validator.ValidateRequestAfterModelMapping(c, newModelAPIRelayInfo("", "")) + if err == nil || err.Code != "invalid_request" || err.StatusCode != http.StatusBadRequest { + t.Fatalf("error = %+v, want invalid_request 400", err) + } + }) + + t.Run("allows /v1/videos and preserves payload validation", func(t *testing.T) { + c, _ := newModelAPITestContext(validBody) + if err := validator.ValidateRequestAfterModelMapping(c, newModelAPIRelayInfo("", "")); err != nil { + t.Fatalf("/v1/videos rejected: %+v", err) + } + + c, _ = newModelAPITestContext(`{"model":"client-model","duration":31,"content":[{"type":"text","text":"hello"}]}`) + err := validator.ValidateRequestAfterModelMapping(c, newModelAPIRelayInfo("", "")) + if err == nil || err.Code != "invalid_request" || !strings.Contains(err.Message, "duration") { + t.Fatalf("invalid payload error = %+v, want duration invalid_request", err) + } + }) +} + +func TestBuildAndFetchPathsHeadersAndEscaping(t *testing.T) { + service.InitHttpClient() + a := &TaskAdaptor{} + info := newModelAPIRelayInfo("https://api.modelapi.co///", "secret") + a.Init(info) + if a.baseURL != "https://api.modelapi.co" { + t.Fatalf("baseURL = %q, want trimmed", a.baseURL) + } + if got, err := a.BuildRequestURL(info); err != nil || got != "https://api.modelapi.co/v1/tasks" { + t.Fatalf("BuildRequestURL = %q, %v", got, err) + } + req := httptest.NewRequest(http.MethodPost, "/upstream", nil) + if err := a.BuildRequestHeader(nil, req, info); err != nil { + t.Fatalf("BuildRequestHeader error: %v", err) + } + if req.Header.Get("Authorization") != "Bearer secret" { + t.Fatalf("Authorization = %q", req.Header.Get("Authorization")) + } + + var gotPath, gotAuth string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.EscapedPath() + gotAuth = r.Header.Get("Authorization") + _, _ = w.Write([]byte(`{"task_id":"ok","status":"running"}`)) + })) + defer server.Close() + resp, err := a.FetchTask(server.URL, "fetch-key", map[string]any{"task_id": "task/a b"}, "") + if err != nil { + t.Fatalf("FetchTask error: %v", err) + } + _ = resp.Body.Close() + if gotPath != "/v1/tasks/task%2Fa%20b" { + t.Fatalf("fetch path = %q", gotPath) + } + if gotAuth != "Bearer fetch-key" { + t.Fatalf("fetch auth = %q", gotAuth) + } +} + +func TestDoRequestRejectsProxyWithoutUpstreamRequest(t *testing.T) { + a := &TaskAdaptor{} + c, _ := newModelAPITestContext(`{}`) + info := newModelAPIRelayInfo("", "secret") + info.ChannelSetting.Proxy = "http://proxy.internal:8080" + + resp, err := a.DoRequest(c, info, strings.NewReader(`{}`)) + if resp != nil { + _ = resp.Body.Close() + t.Fatalf("DoRequest returned response with proxy configured") + } + if err == nil { + t.Fatal("expected proxy rejection") + } + if err.Error() != "this channel type does not support proxy" { + t.Fatalf("error = %q", err.Error()) + } + assertNoModelAPILeak(t, err.Error()) +} + +func TestDoRequestTreatsWhitespaceProxyAsEmpty(t *testing.T) { + service.InitHttpClient() + t.Cleanup(service.ResetProxyClientCache) + + var requestCount int + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requestCount++ + _, _ = w.Write([]byte(`{"task_id":"ok","status":"pending"}`)) + })) + defer server.Close() + + a := &TaskAdaptor{} + info := newModelAPIRelayInfo(server.URL, "secret") + info.ChannelSetting.Proxy = " \t\n " + a.Init(info) + c, _ := newModelAPITestContext(`{}`) + + resp, err := a.DoRequest(c, info, strings.NewReader(`{}`)) + if err != nil { + t.Fatalf("DoRequest rejected whitespace proxy: %v", err) + } + _ = resp.Body.Close() + if requestCount != 1 { + t.Fatalf("DoRequest reached upstream %d times, want 1", requestCount) + } + if info.ChannelSetting.Proxy != " \t\n " { + t.Fatalf("DoRequest mutated RelayInfo proxy to %q", info.ChannelSetting.Proxy) + } +} + +func TestFetchTaskRejectsProxyWithoutUpstreamRequest(t *testing.T) { + a := &TaskAdaptor{} + var requestCount int + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requestCount++ + _, _ = w.Write([]byte(`{"task_id":"ok","status":"running"}`)) + })) + defer server.Close() + + resp, err := a.FetchTask(server.URL, "fetch-key", map[string]any{"task_id": "task-1"}, "http://proxy.internal:8080") + if resp != nil { + _ = resp.Body.Close() + t.Fatalf("FetchTask returned response with proxy configured") + } + if err == nil { + t.Fatal("expected proxy rejection") + } + if err.Error() != "this channel type does not support proxy" { + t.Fatalf("error = %q", err.Error()) + } + if requestCount != 0 { + t.Fatalf("FetchTask reached upstream %d times", requestCount) + } + assertNoModelAPILeak(t, err.Error()) +} + +func TestFetchTaskWithContextTreatsWhitespaceProxyAsEmpty(t *testing.T) { + service.InitHttpClient() + t.Cleanup(service.ResetProxyClientCache) + + a := &TaskAdaptor{} + var requestCount int + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requestCount++ + _, _ = w.Write([]byte(`{"task_id":"ok","status":"running"}`)) + })) + defer server.Close() + + resp, err := a.FetchTaskWithContext(context.Background(), server.URL, "fetch-key", map[string]any{"task_id": "task-1"}, " \t\n ") + if err != nil { + t.Fatalf("FetchTaskWithContext rejected whitespace proxy: %v", err) + } + _ = resp.Body.Close() + if requestCount != 1 { + t.Fatalf("FetchTaskWithContext reached upstream %d times, want 1", requestCount) + } +} + +func TestInitFallsBackToDefaultBaseURL(t *testing.T) { + a := &TaskAdaptor{} + a.Init(newModelAPIRelayInfo("", "key")) + if a.baseURL != constant.ChannelBaseURLs[constant.ChannelTypeModelAPISeedance] { + t.Fatalf("baseURL = %q", a.baseURL) + } +} + +func TestDoResponseParsesExactTaskIDAndRejectsIDOnly(t *testing.T) { + a := &TaskAdaptor{} + info := newModelAPIRelayInfo("", "") + c, w := newModelAPITestContext(`{}`) + resp := &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(`{"task_id":"upstream-task","status":"pending"}`))} + taskID, taskData, taskErr := a.DoResponse(c, resp, info) + if taskErr != nil { + t.Fatalf("DoResponse error: %+v", taskErr) + } + if taskID != "upstream-task" { + t.Fatalf("taskID = %q", taskID) + } + if strings.Contains(string(taskData), "upstream-task") || strings.Contains(string(taskData), "task_id") { + t.Fatalf("persisted taskData leaked upstream task id: %s", taskData) + } + if !strings.Contains(string(taskData), `"status":"pending"`) { + t.Fatalf("persisted taskData missed safe submit status: %s", taskData) + } + if strings.Contains(w.Body.String(), "upstream-task") || !strings.Contains(w.Body.String(), `"id":"task_public"`) { + t.Fatalf("client response leaked upstream or missed public id: %s", w.Body.String()) + } + + c, _ = newModelAPITestContext(`{}`) + resp = &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(`{"id":"wrong-field","status":"pending"}`))} + taskID, _, taskErr = a.DoResponse(c, resp, info) + if taskErr == nil { + t.Fatal("expected id-only response to be rejected") + } + if taskID != "" { + t.Fatalf("taskID = %q, want empty on error", taskID) + } + if strings.Contains(taskErr.Message, "ModelAPI") || strings.Contains(taskErr.Message, "api.modelapi.co") { + t.Fatalf("task error leaked provider: %+v", taskErr) + } + + c, _ = newModelAPITestContext(`{}`) + resp = &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(`{"task_id":"upstream-task","status":"failed","error":{"message":"ModelAPI api.modelapi.co failed"}}`))} + _, _, taskErr = a.DoResponse(c, resp, info) + if taskErr == nil { + t.Fatal("expected failed create response to be rejected") + } + if taskErr.Message != "task failed at upstream provider" { + t.Fatalf("failed create message = %q", taskErr.Message) + } +} + +func TestDoResponseRejectsOversizedSubmitBodyWithoutReadingPastLimitOrLeaking(t *testing.T) { + a := &TaskAdaptor{} + c, _ := newModelAPITestContext(`{}`) + body := &countingReadCloser{Reader: strings.NewReader(strings.Repeat("x", (1<<20)+128) + " ModelAPI api.modelapi.co upstream-secret-id")} + resp := &http.Response{StatusCode: http.StatusOK, Body: body} + + taskID, taskData, taskErr := a.DoResponse(c, resp, newModelAPIRelayInfo("", "")) + if taskErr == nil { + t.Fatal("expected oversized submit response to be rejected") + } + if taskID != "" || taskData != nil { + t.Fatalf("oversized response returned taskID=%q taskData=%s", taskID, taskData) + } + if taskErr.Code != "invalid_response" || taskErr.StatusCode != http.StatusBadGateway { + t.Fatalf("taskErr = %+v, want invalid_response/502", taskErr) + } + if body.n > (1<<20)+1 { + t.Fatalf("DoResponse read %d bytes, want at most max+1", body.n) + } + assertNoModelAPILeak(t, taskErr.Message) +} + +func TestDoResponseClosesSubmitBodyOnReadError(t *testing.T) { + a := &TaskAdaptor{} + c, _ := newModelAPITestContext(`{}`) + body := &submitReadErrorCloser{err: errors.New("read ModelAPI api.modelapi.co upstream-secret-id failed")} + resp := &http.Response{StatusCode: http.StatusOK, Body: body} + + taskID, taskData, taskErr := a.DoResponse(c, resp, newModelAPIRelayInfo("", "")) + if taskErr == nil { + t.Fatal("expected read error") + } + if taskID != "" || taskData != nil { + t.Fatalf("read error returned taskID=%q taskData=%s", taskID, taskData) + } + if !body.closed { + t.Fatal("DoResponse did not close submit response body on read error") + } + if taskErr.Code != "read_response_body_failed" || taskErr.StatusCode != http.StatusInternalServerError { + t.Fatalf("taskErr = %+v, want read_response_body_failed/500", taskErr) + } + assertNoModelAPILeak(t, taskErr.Message) +} + +type countingReadCloser struct { + *strings.Reader + n int +} + +func (c *countingReadCloser) Read(p []byte) (int, error) { + n, err := c.Reader.Read(p) + c.n += n + return n, err +} + +func (c *countingReadCloser) Close() error { return nil } + +type submitReadErrorCloser struct { + err error + closed bool +} + +func (c *submitReadErrorCloser) Read([]byte) (int, error) { + return 0, c.err +} + +func (c *submitReadErrorCloser) Close() error { + c.closed = true + return nil +} + +func TestDoResponseReturnsFailedStatusBeforeMissingTaskIDAndUsesErrorCodeFallback(t *testing.T) { + a := &TaskAdaptor{} + info := newModelAPIRelayInfo("", "") + c, _ := newModelAPITestContext(`{}`) + resp := &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(`{"status":"failed","error":{"message":"ModelAPI api.modelapi.co rejected https://api.modelapi.co/v1/tasks/real"}}`))} + _, _, taskErr := a.DoResponse(c, resp, info) + if taskErr == nil { + t.Fatal("expected failed create response to be rejected") + } + if taskErr.Code != "upstream_error" { + t.Fatalf("taskErr.Code = %q, want upstream_error", taskErr.Code) + } + if taskErr.Message != "task failed at upstream provider" { + t.Fatalf("failed-without-task_id message = %q", taskErr.Message) + } + assertNoModelAPILeak(t, taskErr.Message) + + c, _ = newModelAPITestContext(`{}`) + resp = &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(`{"status":"failed","error":{"code":"rate_limit_exceeded"}}`))} + _, _, taskErr = a.DoResponse(c, resp, info) + if taskErr == nil { + t.Fatal("expected code-only failed create response to be rejected") + } + if taskErr.Message == "" { + t.Fatal("code-only failed create response returned empty message") + } + if taskErr.Message != "task failed at upstream provider" { + t.Fatalf("code-only failed create message = %q", taskErr.Message) + } + assertNoModelAPILeak(t, taskErr.Message) + + c, _ = newModelAPITestContext(`{}`) + resp = &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(`{"status":"failed","error":{"message":"download failed for https://cdn.example/private.mp4 upstream-task-123"}}`))} + _, _, taskErr = a.DoResponse(c, resp, info) + if taskErr == nil { + t.Fatal("expected unbranded failed create response to be rejected") + } + if taskErr.Message != "task failed at upstream provider" { + t.Fatalf("unbranded failed create message = %q", taskErr.Message) + } + assertNoModelAPILeak(t, taskErr.Message) +} + +func TestFetchTaskWithContextHonorsCanceledContext(t *testing.T) { + type contextTaskFetcher interface { + FetchTaskWithContext(context.Context, string, string, map[string]any, string) (*http.Response, error) + } + fetcher, ok := any(&TaskAdaptor{}).(contextTaskFetcher) + if !ok { + t.Fatal("ModelAPI Seedance adaptor does not support context-aware task polling") + } + + service.InitHttpClient() + requestStarted := make(chan struct{}) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + close(requestStarted) + <-r.Context().Done() + })) + defer server.Close() + t.Cleanup(service.ResetProxyClientCache) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + resp, err := fetcher.FetchTaskWithContext(ctx, server.URL, "fetch-key", map[string]any{"task_id": "task-1"}, "") + if resp != nil { + _ = resp.Body.Close() + t.Fatalf("FetchTaskWithContext returned response for canceled context") + } + if !errors.Is(err, context.Canceled) { + t.Fatalf("FetchTaskWithContext error = %v, want context.Canceled", err) + } + select { + case <-requestStarted: + t.Fatal("canceled context still reached upstream server") + case <-time.After(50 * time.Millisecond): + } +} + +func TestParseTaskResultStatusMappingsAndFailureScrub(t *testing.T) { + a := &TaskAdaptor{} + tests := []struct { + name string + body string + wantStatus string + wantURL string + wantReason string + }{ + {name: "pending queued", body: `{"task_id":"up","status":"pending"}`, wantStatus: model.TaskStatusQueued}, + {name: "polling progress", body: `{"task_id":"up","status":"polling"}`, wantStatus: model.TaskStatusInProgress}, + {name: "running progress", body: `{"task_id":"up","status":"running"}`, wantStatus: model.TaskStatusInProgress}, + {name: "unknown progress", body: `{"task_id":"up","status":"mystery"}`, wantStatus: model.TaskStatusInProgress}, + {name: "succeeded video", body: `{"task_id":"up","status":"succeeded","result":{"assets":[{"type":"image","url":"https://x/i.png"},{"type":"video","url":"https://x/v.mp4"}]}}`, wantStatus: model.TaskStatusSuccess, wantURL: "https://x/v.mp4"}, + {name: "failed scrubbed", body: `{"task_id":"up","status":"failed","error":{"code":"bad","message":"ModelAPI seedance host api.modelapi.co failed"}}`, wantStatus: model.TaskStatusFailure, wantReason: "task failed at upstream provider"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + info, err := a.ParseTaskResult([]byte(tt.body)) + if err != nil { + t.Fatalf("ParseTaskResult error: %v", err) + } + if info.Status != tt.wantStatus || info.Url != tt.wantURL || info.Reason != tt.wantReason { + t.Fatalf("TaskInfo = %+v", info) + } + }) + } + if _, err := a.ParseTaskResult([]byte(`{"task_id":"up","status":"succeeded","result":{"assets":[{"type":"image","url":"https://x/i.png"}]}}`)); err == nil { + t.Fatal("expected missing video asset to be retryable error") + } +} + +func TestParseTaskResultUsesErrorCodeFallbackForFailedTasks(t *testing.T) { + info, err := (&TaskAdaptor{}).ParseTaskResult([]byte(`{"task_id":"up","status":"failed","error":{"code":"quota_exceeded"}}`)) + if err != nil { + t.Fatalf("ParseTaskResult error: %v", err) + } + if info.Status != model.TaskStatusFailure { + t.Fatalf("Status = %q, want failure", info.Status) + } + if info.Reason != "task failed at upstream provider" { + t.Fatalf("code-only failure reason = %q", info.Reason) + } + assertNoModelAPILeak(t, info.Reason) + + info, err = (&TaskAdaptor{}).ParseTaskResult([]byte(`{"task_id":"up","status":"failed","error":{"code":"ModelAPI api.modelapi.co https://api.modelapi.co/v1/tasks/real"}}`)) + if err != nil { + t.Fatalf("ParseTaskResult branded code error: %v", err) + } + if info.Reason != "task failed at upstream provider" { + t.Fatalf("branded code-only failure reason = %q", info.Reason) + } + assertNoModelAPILeak(t, info.Reason) + + info, err = (&TaskAdaptor{}).ParseTaskResult([]byte(`{"task_id":"up","status":"failed","error":{"message":"download failed for https://cdn.example/private.mp4 upstream-task-123"}}`)) + if err != nil { + t.Fatalf("ParseTaskResult unbranded URL error: %v", err) + } + if info.Reason != "task failed at upstream provider" { + t.Fatalf("unbranded URL failure reason = %q", info.Reason) + } + assertNoModelAPILeak(t, info.Reason) +} + +func TestConvertToOpenAIVideoUsesPublicResultURLAndScrubsFailure(t *testing.T) { + a := &TaskAdaptor{} + success := &model.Task{ + TaskID: "task_public", + Status: model.TaskStatusSuccess, + Progress: "100%", + CreatedAt: 10, + UpdatedAt: 20, + Properties: model.Properties{OriginModelName: "client-model"}, + PrivateData: model.TaskPrivateData{ + ResultURL: "https://flatkey.example/v1/videos/task_public/content", + }, + Data: []byte(`{"result":{"assets":[{"type":"video","url":"https://cdn.modelapi.co/private.mp4"}]}}`), + } + raw, err := a.ConvertToOpenAIVideo(success) + if err != nil { + t.Fatalf("ConvertToOpenAIVideo success error: %v", err) + } + var got dto.OpenAIVideo + if err := common.Unmarshal(raw, &got); err != nil { + t.Fatalf("unmarshal video: %v", err) + } + if got.Metadata["url"] != "https://flatkey.example/v1/videos/task_public/content" { + t.Fatalf("metadata.url = %v", got.Metadata["url"]) + } + if strings.Contains(string(raw), "cdn.modelapi.co") { + t.Fatalf("success leaked upstream asset URL: %s", raw) + } + + failure := &model.Task{ + TaskID: "task_public", + Status: model.TaskStatusFailure, + FailReason: "download failed for https://cdn.example/private.mp4 upstream-task-123", + } + raw, err = a.ConvertToOpenAIVideo(failure) + if err != nil { + t.Fatalf("ConvertToOpenAIVideo failure error: %v", err) + } + if err := common.Unmarshal(raw, &got); err != nil { + t.Fatalf("unmarshal failure video: %v", err) + } + if got.Error == nil || got.Error.Message != "task failed at upstream provider" { + t.Fatalf("failure error = %+v", got.Error) + } +} + +func assertNoModelAPILeak(t *testing.T, s string) { + t.Helper() + for _, leaked := range []string{"ModelAPI", "modelapi", "api.modelapi.co", "https://api.modelapi.co/v1/tasks/real", "https://cdn.example/private.mp4", "upstream-task-123"} { + if strings.Contains(s, leaked) { + t.Fatalf("message leaked %q: %q", leaked, s) + } + } +} diff --git a/relay/channel/task/modelapiseedance/billing_test.go b/relay/channel/task/modelapiseedance/billing_test.go new file mode 100644 index 00000000000..3946ae6f13b --- /dev/null +++ b/relay/channel/task/modelapiseedance/billing_test.go @@ -0,0 +1,240 @@ +package modelapiseedance + +import ( + "io" + "math" + "net/http" + "strings" + "testing" + + "github.com/QuantumNous/new-api/common" + relaycommon "github.com/QuantumNous/new-api/relay/common" + "github.com/QuantumNous/new-api/types" +) + +func modelAPIBillingInfo(modelPrice float64) *relaycommon.RelayInfo { + return &relaycommon.RelayInfo{ + OriginModelName: "client-seedance", + ChannelMeta: &relaycommon.ChannelMeta{ + UpstreamModelName: UpstreamModel, + }, + PriceData: types.PriceData{ + UsePrice: true, + ModelPrice: modelPrice, + }, + TaskRelayInfo: &relaycommon.TaskRelayInfo{PublicTaskID: "task_public"}, + } +} + +func assertModelAPIBillableUnits(t *testing.T, got map[string]float64, want float64) { + t.Helper() + if len(got) != 1 { + t.Fatalf("EstimateBilling() = %#v, want only billable_units", got) + } + if math.Abs(got[modelAPIBillingUnitsKey]-want) > 1e-9 { + t.Fatalf("billable_units = %.12f, want %.12f", got[modelAPIBillingUnitsKey], want) + } +} + +func TestEstimateBillingUsesSeedanceDefaultsAndResolutionPricing(t *testing.T) { + tests := []struct { + name string + body string + wantUSD float64 + }{ + { + name: "defaults to 5s 720p without video", + body: `{"model":"client","content":[{"type":"text","text":"make it cinematic"}]}`, + wantUSD: 0.314 * 5, + }, + { + name: "explicit 480p without video", + body: `{"model":"client","duration":4,"resolution":"480p","content":[{"type":"text","text":"make it cinematic"}]}`, + wantUSD: 0.140 * 4, + }, + { + name: "explicit 720p without video", + body: `{"model":"client","duration":10,"resolution":"720p","content":[{"type":"text","text":"make it cinematic"}]}`, + wantUSD: 0.314 * 10, + }, + { + name: "explicit 480p with video uses 30s fallback", + body: `{"model":"client","duration":4,"resolution":"480p","content":[{"type":"video_url","video_url":{"url":"https://example.com/ref.mp4"}}]}`, + wantUSD: 0.084 * 30, + }, + { + name: "explicit 720p with video uses 30s fallback", + body: `{"model":"client","duration":4,"resolution":"720p","content":[{"type":"video_url","video_url":{"url":"https://example.com/ref.mp4"}}]}`, + wantUSD: 0.188 * 30, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + c, _ := newModelAPITestContext(test.body) + got := (&TaskAdaptor{}).EstimateBilling(c, modelAPIBillingInfo(modelAPIBaseModelPrice)) + assertModelAPIBillableUnits(t, got, test.wantUSD/modelAPIBaseModelPrice) + }) + } +} + +func TestValidateTaskPriceDataRequiresFiniteFixedModelPrice(t *testing.T) { + tests := []struct { + name string + info *relaycommon.RelayInfo + }{ + {name: "nil info", info: nil}, + {name: "not fixed price", info: func() *relaycommon.RelayInfo { + info := modelAPIBillingInfo(modelAPIBaseModelPrice) + info.PriceData.UsePrice = false + return info + }()}, + {name: "zero model price", info: modelAPIBillingInfo(0)}, + {name: "negative model price", info: modelAPIBillingInfo(-0.14)}, + {name: "nan model price", info: modelAPIBillingInfo(math.NaN())}, + {name: "infinite model price", info: modelAPIBillingInfo(math.Inf(1))}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + taskErr := (&TaskAdaptor{}).ValidateTaskPriceData(test.info) + if taskErr == nil { + t.Fatal("ValidateTaskPriceData() = nil, want local model_price_error") + } + if taskErr.Code != "model_price_error" || taskErr.StatusCode != http.StatusBadRequest || !taskErr.LocalError { + t.Fatalf("TaskError = %+v, want local model_price_error/400", taskErr) + } + }) + } + + if taskErr := (&TaskAdaptor{}).ValidateTaskPriceData(modelAPIBillingInfo(modelAPIBaseModelPrice)); taskErr != nil { + t.Fatalf("valid fixed price rejected: %+v", taskErr) + } +} + +func TestEstimateBillingReturnsNilForInvalidFixedPriceData(t *testing.T) { + c, _ := newModelAPITestContext(`{"model":"client","content":[{"type":"text","text":"make it cinematic"}]}`) + tests := []*relaycommon.RelayInfo{ + nil, + func() *relaycommon.RelayInfo { + info := modelAPIBillingInfo(modelAPIBaseModelPrice) + info.PriceData.UsePrice = false + return info + }(), + modelAPIBillingInfo(0), + modelAPIBillingInfo(-0.14), + modelAPIBillingInfo(math.NaN()), + modelAPIBillingInfo(math.Inf(1)), + } + for i, info := range tests { + if got := (&TaskAdaptor{}).EstimateBilling(c, info); len(got) != 0 { + t.Fatalf("case %d EstimateBilling() = %#v, want nil", i, got) + } + } +} + +func TestDoResponsePersistsPrivateEstimateAndAdjustsSubmitBilling(t *testing.T) { + a := &TaskAdaptor{} + info := modelAPIBillingInfo(modelAPIBaseModelPrice) + c, w := newModelAPITestContext(`{}`) + resp := &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(`{ + "task_id":"upstream-secret", + "status":"pending", + "usage":{"estimated_usd":1.57}, + "result":{"assets":[{"type":"video","url":"https://cdn.modelapi.co/private.mp4"}]} + }`))} + + taskID, taskData, taskErr := a.DoResponse(c, resp, info) + if taskErr != nil { + t.Fatalf("DoResponse error: %+v", taskErr) + } + if taskID != "upstream-secret" { + t.Fatalf("taskID = %q, want upstream id returned only internally", taskID) + } + var snapshot struct { + Status string `json:"status"` + EstimatedUSD *float64 `json:"estimated_usd,omitempty"` + } + if err := common.Unmarshal(taskData, &snapshot); err != nil { + t.Fatalf("taskData is invalid JSON: %v", err) + } + if snapshot.Status != "pending" || snapshot.EstimatedUSD == nil || *snapshot.EstimatedUSD != 1.57 { + t.Fatalf("taskData snapshot = %s, want private status and estimated_usd", taskData) + } + public := w.Body.String() + for _, leaked := range []string{"estimated_usd", "upstream-secret", "cdn.modelapi.co", "private.mp4"} { + if strings.Contains(public, leaked) { + t.Fatalf("public response leaked %q: %s", leaked, public) + } + } + + adjusted := a.AdjustBillingOnSubmit(info, taskData) + assertModelAPIBillableUnits(t, adjusted, 1.57/modelAPIBaseModelPrice) +} + +func TestDoResponseKeepsFallbackReservationWhenEstimateIsInvalidOrAbsent(t *testing.T) { + tests := []struct { + name string + body string + }{ + {name: "missing usage", body: `{"task_id":"upstream","status":"pending"}`}, + {name: "usage null", body: `{"task_id":"upstream","status":"pending","usage":null}`}, + {name: "estimate null", body: `{"task_id":"upstream","status":"pending","usage":{"estimated_usd":null}}`}, + {name: "estimate zero", body: `{"task_id":"upstream","status":"pending","usage":{"estimated_usd":0}}`}, + {name: "estimate negative", body: `{"task_id":"upstream","status":"pending","usage":{"estimated_usd":-1}}`}, + {name: "estimate string nan", body: `{"task_id":"upstream","status":"pending","usage":{"estimated_usd":"NaN"}}`}, + {name: "estimate overflow", body: `{"task_id":"upstream","status":"pending","usage":{"estimated_usd":1e999}}`}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + c, w := newModelAPITestContext(`{}`) + resp := &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(test.body))} + taskID, taskData, taskErr := (&TaskAdaptor{}).DoResponse(c, resp, modelAPIBillingInfo(modelAPIBaseModelPrice)) + if taskErr != nil { + t.Fatalf("DoResponse error: %+v", taskErr) + } + if taskID != "upstream" { + t.Fatalf("taskID = %q, want upstream", taskID) + } + if strings.Contains(string(taskData), "estimated_usd") { + t.Fatalf("invalid estimate persisted in taskData: %s", taskData) + } + if got := (&TaskAdaptor{}).AdjustBillingOnSubmit(modelAPIBillingInfo(modelAPIBaseModelPrice), taskData); len(got) != 0 { + t.Fatalf("AdjustBillingOnSubmit() = %#v, want nil fallback", got) + } + if strings.Contains(w.Body.String(), "estimated_usd") { + t.Fatalf("public response exposed invalid estimate: %s", w.Body.String()) + } + }) + } +} + +func TestAdjustBillingOnSubmitKeepsReservationForInvalidSnapshotOrPrice(t *testing.T) { + validData := []byte(`{"status":"pending","estimated_usd":1.57}`) + tests := []struct { + name string + info *relaycommon.RelayInfo + data []byte + }{ + {name: "malformed taskData", info: modelAPIBillingInfo(modelAPIBaseModelPrice), data: []byte(`{"status":`)}, + {name: "missing estimate", info: modelAPIBillingInfo(modelAPIBaseModelPrice), data: []byte(`{"status":"pending"}`)}, + {name: "estimate zero", info: modelAPIBillingInfo(modelAPIBaseModelPrice), data: []byte(`{"status":"pending","estimated_usd":0}`)}, + {name: "estimate negative", info: modelAPIBillingInfo(modelAPIBaseModelPrice), data: []byte(`{"status":"pending","estimated_usd":-1}`)}, + {name: "nil info", info: nil, data: validData}, + {name: "non fixed price", info: func() *relaycommon.RelayInfo { + info := modelAPIBillingInfo(modelAPIBaseModelPrice) + info.PriceData.UsePrice = false + return info + }(), data: validData}, + {name: "invalid model price", info: modelAPIBillingInfo(math.Inf(1)), data: validData}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if got := (&TaskAdaptor{}).AdjustBillingOnSubmit(test.info, test.data); len(got) != 0 { + t.Fatalf("AdjustBillingOnSubmit() = %#v, want nil fallback", got) + } + }) + } +} diff --git a/relay/channel/task/modelapiseedance/constants.go b/relay/channel/task/modelapiseedance/constants.go new file mode 100644 index 00000000000..7feb229cb35 --- /dev/null +++ b/relay/channel/task/modelapiseedance/constants.go @@ -0,0 +1,11 @@ +package modelapiseedance + +const ChannelName = "modelapi-seedance" +const UpstreamModel = "doubao-seedance-2-5-260628" +const maxModelAPISubmitResponseBytes = 1 << 20 +const modelAPIBaseModelPrice = 0.14 +const modelAPIBillingUnitsKey = "billable_units" + +var ModelList = []string{ + UpstreamModel, +} diff --git a/relay/channel/task/taskcommon/helpers.go b/relay/channel/task/taskcommon/helpers.go index 0538c35d9b9..153d6f59479 100644 --- a/relay/channel/task/taskcommon/helpers.go +++ b/relay/channel/task/taskcommon/helpers.go @@ -28,6 +28,7 @@ var whitelabelChannels = map[int]struct{}{ constant.ChannelTypeJimengZhizinan: {}, constant.ChannelTypeTechMobiVideo: {}, constant.ChannelTypeBytePlus: {}, + constant.ChannelTypeModelAPISeedance: {}, constant.ChannelTypeXaiGrokVideo: {}, constant.ChannelTypeSonilo: {}, } @@ -65,6 +66,7 @@ var brandKeywords = []string{ "jimeng", "jianying", "dreamina", "seedance", "techmobi", "chatgpttech", "byteplus", + "modelapi", "api.modelapi.co", "xai", "grok", "x.ai", "vidgen.x.ai", "sonilo", "api.sonilo.com", } diff --git a/relay/channel/task/taskcommon/helpers_test.go b/relay/channel/task/taskcommon/helpers_test.go index 1b61af7d56d..655e42fde72 100644 --- a/relay/channel/task/taskcommon/helpers_test.go +++ b/relay/channel/task/taskcommon/helpers_test.go @@ -18,6 +18,7 @@ func TestShouldWhitelabelPlatform(t *testing.T) { {"jimeng zhizinan (channel 104)", constant.TaskPlatform("104"), true}, {"techmobi video (channel 105)", constant.TaskPlatform("105"), true}, {"byteplus (channel 107)", constant.TaskPlatform("107"), true}, + {"modelapi (channel 111)", constant.TaskPlatform("111"), true}, {"openai channel type number", constant.TaskPlatform("1"), false}, {"non-numeric platform suno", constant.TaskPlatformSuno, false}, {"empty platform", constant.TaskPlatform(""), false}, @@ -51,6 +52,9 @@ func TestShouldWhitelabelChannelType(t *testing.T) { if !ShouldWhitelabelChannelType(constant.ChannelTypeBytePlus) { t.Errorf("expected BytePlus channel type %d to be whitelabeled", constant.ChannelTypeBytePlus) } + if !ShouldWhitelabelChannelType(constant.ChannelTypeModelAPISeedance) { + t.Errorf("expected ModelAPI channel type %d to be whitelabeled", constant.ChannelTypeModelAPISeedance) + } if ShouldWhitelabelChannelType(0) { t.Error("zero channel type should not be whitelabeled") } @@ -81,6 +85,8 @@ func TestScrubBrandedText(t *testing.T) { {"contains techmobi name", "TechMobi task failed", generic}, {"contains byteplus host", "ark.ap-southeast.bytepluses.com returned 500", generic}, {"contains byteplus name", "BytePlus task failed", generic}, + {"contains modelapi host", "api.modelapi.co returned 500", generic}, + {"contains modelapi name", "ModelAPI seedance failed", generic}, {"contains endpoint id", "endpoint ep-test-secret rejected the request", generic}, {"unrelated word with substring", "kuai noodles", "kuai noodles"}, } diff --git a/relay/channel/task/taskcommon/seedance.go b/relay/channel/task/taskcommon/seedance.go index 3f8ab2f415e..0c948d2aa0f 100644 --- a/relay/channel/task/taskcommon/seedance.go +++ b/relay/channel/task/taskcommon/seedance.go @@ -60,10 +60,20 @@ func BindSeedanceRequest(c *gin.Context, info *relaycommon.RelayInfo, action str } relaycommon.StoreTaskRequest(c, info, action, taskReq) - c.Set(seedanceRequestContextKey, &req) + SetSeedanceRequest(c, &req) return &req, nil } +// SetSeedanceRequest updates the parsed seedance request cache for adaptors +// that need to derive an immutable post-processed copy after BindSeedanceRequest +// has already synthesized the public task_request. +func SetSeedanceRequest(c *gin.Context, req *dto.SeedanceVideoRequest) { + if c == nil || req == nil { + return + } + c.Set(seedanceRequestContextKey, req) +} + // GetSeedanceRequest returns the seedance request parsed by BindSeedanceRequest // earlier in the same request, avoiding a redundant body decode for read-only // consumers (e.g. a channel's EstimateBilling). If no bound request is cached — diff --git a/relay/relay_adaptor.go b/relay/relay_adaptor.go index 16e6be26a6e..29d9a7e5535 100644 --- a/relay/relay_adaptor.go +++ b/relay/relay_adaptor.go @@ -46,6 +46,7 @@ import ( taskjimengzhizinan "github.com/QuantumNous/new-api/relay/channel/task/jimengzhizinan" "github.com/QuantumNous/new-api/relay/channel/task/kling" taskkuaizi "github.com/QuantumNous/new-api/relay/channel/task/kuaizi" + taskmodelapiseedance "github.com/QuantumNous/new-api/relay/channel/task/modelapiseedance" tasksonilo "github.com/QuantumNous/new-api/relay/channel/task/sonilo" tasksora "github.com/QuantumNous/new-api/relay/channel/task/sora" "github.com/QuantumNous/new-api/relay/channel/task/suno" @@ -223,6 +224,8 @@ func GetTaskAdaptor(platform constant.TaskPlatform) channel.TaskAdaptor { return &tasktechmobi.TaskAdaptor{} case constant.ChannelTypeBytePlus: return &taskbyteplus.TaskAdaptor{} + case constant.ChannelTypeModelAPISeedance: + return &taskmodelapiseedance.TaskAdaptor{} case constant.ChannelTypeXaiGrokVideo: return &taskxaigrok.TaskAdaptor{} case constant.ChannelTypeSonilo: diff --git a/relay/relay_adaptor_test.go b/relay/relay_adaptor_test.go index 72742c21b51..59eb142b391 100644 --- a/relay/relay_adaptor_test.go +++ b/relay/relay_adaptor_test.go @@ -8,6 +8,7 @@ import ( "github.com/QuantumNous/new-api/relay/channel/task/byteplus" "github.com/QuantumNous/new-api/relay/channel/task/hailuo" hailuov2 "github.com/QuantumNous/new-api/relay/channel/task/hailuo_v2" + "github.com/QuantumNous/new-api/relay/channel/task/modelapiseedance" "github.com/QuantumNous/new-api/relay/channel/task/sonilo" ) @@ -103,3 +104,16 @@ func TestGetTaskAdaptor_MiniMaxVersions(t *testing.T) { }) } } + +func TestGetTaskAdaptor_ModelAPISeedance(t *testing.T) { + adaptor := GetTaskAdaptor(constant.TaskPlatform(strconv.Itoa(constant.ChannelTypeModelAPISeedance))) + if adaptor == nil { + t.Fatal("expected ModelAPISeedance task adaptor") + } + if _, ok := adaptor.(*modelapiseedance.TaskAdaptor); !ok { + t.Fatalf("adaptor type = %T, want *modelapiseedance.TaskAdaptor", adaptor) + } + if got := adaptor.GetChannelName(); got != "modelapi-seedance" { + t.Fatalf("channel name = %q, want modelapi-seedance", got) + } +} diff --git a/relay/relay_task.go b/relay/relay_task.go index 6fd83aef061..d446ef713cf 100644 --- a/relay/relay_task.go +++ b/relay/relay_task.go @@ -306,13 +306,17 @@ func ExecutePreparedTaskSubmit(c *gin.Context, info *relaycommon.RelayInfo, pref // 11. 解析响应 upstreamTaskID, taskData, taskErr := adaptor.DoResponse(c, resp, info) if taskErr != nil { - return &TaskSubmitResult{ - UpstreamTaskID: upstreamTaskID, - TaskData: taskData, - Platform: platform, - Quota: preflight.Quota, - OutcomeMayBeUnknown: true, - }, taskErr + result := &TaskSubmitResult{ + UpstreamTaskID: upstreamTaskID, + TaskData: taskData, + Platform: platform, + Quota: preflight.Quota, + } + if info != nil && info.ChannelType == constant.ChannelTypeModelAPISeedance && taskErr.StatusCode == http.StatusTooManyRequests { + return result, taskErr + } + result.OutcomeMayBeUnknown = true + return result, taskErr } // 11. 提交后计费调整:让适配器根据上游实际返回调整 OtherRatios @@ -348,16 +352,29 @@ func applyTaskOtherRatios(priceData *types.PriceData) { } } +const ( + taskSubmitErrorResponseMaxBytes = 1 << 20 + taskSubmitErrorFallbackMessage = "upstream task submit failed" +) + func taskSubmitStatusError(platform constant.TaskPlatform, resp *http.Response) *dto.TaskError { statusCode := http.StatusInternalServerError if resp != nil { statusCode = resp.StatusCode } var responseBody []byte + var readErr error if resp != nil && resp.Body != nil { - responseBody, _ = io.ReadAll(resp.Body) + defer func() { _ = resp.Body.Close() }() + responseBody, readErr = io.ReadAll(io.LimitReader(resp.Body, taskSubmitErrorResponseMaxBytes+1)) + if len(responseBody) > taskSubmitErrorResponseMaxBytes { + responseBody = responseBody[:taskSubmitErrorResponseMaxBytes] + } } message := string(responseBody) + if readErr != nil || strings.TrimSpace(message) == "" { + message = taskSubmitErrorFallbackMessage + } if channelType, err := strconv.Atoi(string(platform)); err == nil && taskcommon.ShouldWhitelabelChannelType(channelType) { message = "task failed at upstream provider" } @@ -486,6 +503,10 @@ func videoFetchByIDRespBodyBuilder(c *gin.Context) (respBody []byte, taskResp *d isOpenAIVideoAPI := isOpenAIVideoFetchPath(c.Request.URL.Path) isVideoToMusicAPI := isVideoToMusicFetchPath(c.Request.URL.Path) isGenerationTasksAPI := isGenerationTasksFetchPath(c.Request.URL.Path) + if isModelAPISeedanceTask(originTask) && !isOpenAIVideoAPI { + taskResp = service.TaskErrorWrapperLocal(errors.New("task is not available on this endpoint"), "invalid_request", http.StatusBadRequest) + return + } // Gemini/Vertex 支持实时查询:用户 fetch 时直接从上游拉取最新状态 if realtimeResp := tryRealtimeFetch(originTask, isOpenAIVideoAPI || isGenerationTasksAPI); len(realtimeResp) > 0 { @@ -565,6 +586,13 @@ func isGenerationTasksFetchPath(path string) bool { return strings.HasPrefix(path, "/v1/generation/tasks/") } +func isModelAPISeedanceTask(task *model.Task) bool { + if task == nil { + return false + } + return task.Platform == constant.TaskPlatform(strconv.Itoa(constant.ChannelTypeModelAPISeedance)) +} + type generationTaskVideoURL struct { URL string `json:"url"` } diff --git a/relay/relay_task_billing_test.go b/relay/relay_task_billing_test.go index 17b631e113f..ccea3867d84 100644 --- a/relay/relay_task_billing_test.go +++ b/relay/relay_task_billing_test.go @@ -6,10 +6,12 @@ import ( "strings" "testing" + "github.com/QuantumNous/new-api/common" "github.com/QuantumNous/new-api/relay/channel/task/byteplus" relaycommon "github.com/QuantumNous/new-api/relay/common" "github.com/QuantumNous/new-api/relay/helper" "github.com/QuantumNous/new-api/setting/ratio_setting" + "github.com/QuantumNous/new-api/types" "github.com/gin-gonic/gin" ) @@ -112,3 +114,38 @@ func TestBytePlusModelRatiosApplyTierRatios(t *testing.T) { }) } } + +func TestModelAPISeedanceSubmitAdjustmentPreservesGroupRatio(t *testing.T) { + const ( + modelPrice = 0.14 + groupRatio = 0.8 + reservedUSD = 0.314 * 5 + actualUSD = 1.25 + ) + + reservedBillableUnits := reservedUSD / modelPrice + actualBillableUnits := actualUSD / modelPrice + baseQuotaWithGroupRatio := int(modelPrice * common.QuotaPerUnit * groupRatio) + + info := &relaycommon.RelayInfo{ + PriceData: types.PriceData{ + ModelPrice: modelPrice, + UsePrice: true, + Quota: int(float64(baseQuotaWithGroupRatio) * reservedBillableUnits), + OtherRatios: map[string]float64{ + "billable_units": reservedBillableUnits, + }, + GroupRatioInfo: types.GroupRatioInfo{ + GroupRatio: groupRatio, + }, + }, + } + + got := recalcQuotaFromRatios(info, map[string]float64{ + "billable_units": actualBillableUnits, + }) + want := int(actualUSD * common.QuotaPerUnit * groupRatio) + if got != want { + t.Fatalf("adjusted quota = %d, want %d", got, want) + } +} diff --git a/relay/relay_task_submit_error_test.go b/relay/relay_task_submit_error_test.go index fb108f5c41f..e1de983f280 100644 --- a/relay/relay_task_submit_error_test.go +++ b/relay/relay_task_submit_error_test.go @@ -1,6 +1,7 @@ package relay import ( + "errors" "io" "net/http" "strings" @@ -66,6 +67,143 @@ func TestTaskSubmitStatusErrorPreservesDoubaoBody(t *testing.T) { } } +func TestTaskSubmitStatusErrorScrubsModelAPICapacityNonOKBody(t *testing.T) { + resp := &http.Response{ + StatusCode: http.StatusTooManyRequests, + Body: io.NopCloser(strings.NewReader( + `{"error":{"message":"Selected model is at capacity. Please try a different model.","task_id":"upstream-secret-id"}}`)), + } + + taskErr := taskSubmitStatusError(constant.TaskPlatform("111"), resp) + if taskErr == nil { + t.Fatal("expected task submit status error") + } + if taskErr.StatusCode != http.StatusTooManyRequests { + t.Fatalf("status = %d, want %d", taskErr.StatusCode, http.StatusTooManyRequests) + } + if taskErr.Message != "task failed at upstream provider" { + t.Fatalf("message = %q, want fixed generic message", taskErr.Message) + } + for _, marker := range []string{"Selected model", "different model", "upstream-secret-id", "ModelAPI"} { + if strings.Contains(taskErrorText(taskErr), marker) { + t.Fatalf("non-200 submit error leaked %q in %q", marker, taskErrorText(taskErr)) + } + } +} + +func TestTaskSubmitStatusErrorClosesAndBoundsNonOKBody(t *testing.T) { + body := &countingSubmitErrorBody{Reader: strings.NewReader(strings.Repeat("x", (1<<20)+128) + " upstream-secret-id")} + resp := &http.Response{ + StatusCode: http.StatusBadGateway, + Body: body, + } + + taskErr := taskSubmitStatusError(constant.TaskPlatform("111"), resp) + if taskErr == nil { + t.Fatal("expected task submit status error") + } + if !body.closed { + t.Fatal("taskSubmitStatusError did not close non-200 response body") + } + if body.n > (1<<20)+1 { + t.Fatalf("taskSubmitStatusError read %d bytes, want at most max+1", body.n) + } + if taskErr.Message != "task failed at upstream provider" { + t.Fatalf("message = %q, want fixed generic message", taskErr.Message) + } + if strings.Contains(taskErrorText(taskErr), "upstream-secret-id") { + t.Fatalf("oversized submit error leaked raw body in %q", taskErrorText(taskErr)) + } +} + +func TestTaskSubmitStatusErrorPreservesBoundedDoubaoOversizedBody(t *testing.T) { + const prefix = "doubao bounded prefix" + const tail = "doubao oversized tail" + bodyText := prefix + strings.Repeat("x", taskSubmitErrorResponseMaxBytes) + tail + body := &countingSubmitErrorBody{Reader: strings.NewReader(bodyText)} + resp := &http.Response{ + StatusCode: http.StatusBadGateway, + Body: body, + } + + taskErr := taskSubmitStatusError(constant.TaskPlatform("52"), resp) + if taskErr == nil { + t.Fatal("expected task submit status error") + } + if !body.closed { + t.Fatal("taskSubmitStatusError did not close non-200 response body") + } + if body.n > taskSubmitErrorResponseMaxBytes+1 { + t.Fatalf("taskSubmitStatusError read %d bytes, want at most max+1", body.n) + } + if !strings.Contains(taskErr.Message, prefix) { + t.Fatalf("doubao submit error did not preserve bounded prefix: %q", taskErr.Message) + } + if strings.Contains(taskErr.Message, tail) { + t.Fatalf("doubao submit error leaked oversized tail in %q", taskErr.Message) + } +} + +func TestTaskSubmitStatusErrorUsesFallbackForEmptyBody(t *testing.T) { + resp := &http.Response{ + StatusCode: http.StatusBadGateway, + Body: io.NopCloser(strings.NewReader("")), + } + + taskErr := taskSubmitStatusError(constant.TaskPlatform("52"), resp) + if taskErr == nil { + t.Fatal("expected task submit status error") + } + if strings.TrimSpace(taskErr.Message) == "" { + t.Fatal("expected non-empty fallback message for empty submit error body") + } +} + +func TestTaskSubmitStatusErrorUsesFallbackForReadError(t *testing.T) { + resp := &http.Response{ + StatusCode: http.StatusBadGateway, + Body: &readErrorSubmitErrorBody{}, + } + + taskErr := taskSubmitStatusError(constant.TaskPlatform("52"), resp) + if taskErr == nil { + t.Fatal("expected task submit status error") + } + if strings.TrimSpace(taskErr.Message) == "" { + t.Fatal("expected non-empty fallback message for unreadable submit error body") + } +} + +type countingSubmitErrorBody struct { + *strings.Reader + n int + closed bool +} + +func (b *countingSubmitErrorBody) Read(p []byte) (int, error) { + n, err := b.Reader.Read(p) + b.n += n + return n, err +} + +func (b *countingSubmitErrorBody) Close() error { + b.closed = true + return nil +} + +type readErrorSubmitErrorBody struct { + closed bool +} + +func (b *readErrorSubmitErrorBody) Read(_ []byte) (int, error) { + return 0, errors.New("read failed") +} + +func (b *readErrorSubmitErrorBody) Close() error { + b.closed = true + return nil +} + func taskErrorText(taskErr *dto.TaskError) string { if taskErr == nil { return "" diff --git a/relay/relay_task_usage_test.go b/relay/relay_task_usage_test.go index 1ac3c416c81..dc4f272920c 100644 --- a/relay/relay_task_usage_test.go +++ b/relay/relay_task_usage_test.go @@ -1,11 +1,21 @@ package relay import ( + "net/http" + "net/http/httptest" + "strconv" + "strings" "testing" "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/constant" "github.com/QuantumNous/new-api/dto" "github.com/QuantumNous/new-api/model" + relaycommon "github.com/QuantumNous/new-api/relay/common" + relayconstant "github.com/QuantumNous/new-api/relay/constant" + "github.com/gin-gonic/gin" + "github.com/glebarez/sqlite" + "gorm.io/gorm" ) func TestInjectUsageFromPrivateData(t *testing.T) { @@ -165,3 +175,174 @@ func TestGenerationTaskRespBodyFailureScrubsError(t *testing.T) { t.Fatalf("error = %+v", got.Error) } } + +func TestModelAPISeedancePrepareTaskAttemptRejectsLegacySubmitPathBeforePricing(t *testing.T) { + gin.SetMode(gin.TestMode) + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/video/generations", strings.NewReader( + `{"model":"doubao-seedance-2-5-260628","content":[{"type":"text","text":"hello"}]}`, + )) + c.Request.Header.Set("Content-Type", "application/json") + c.Set("platform", strconv.Itoa(constant.ChannelTypeModelAPISeedance)) + common.SetContextKey(c, constant.ContextKeyChannelType, constant.ChannelTypeModelAPISeedance) + common.SetContextKey(c, constant.ContextKeyChannelKey, "test-key") + common.SetContextKey(c, constant.ContextKeyChannelBaseUrl, "https://api.modelapi.co") + + _, taskErr := PrepareTaskAttempt(c, &relaycommon.RelayInfo{ + OriginModelName: "doubao-seedance-2-5-260628", + UsingGroup: "default", + UserGroup: "default", + TaskRelayInfo: &relaycommon.TaskRelayInfo{}, + }) + if taskErr == nil { + t.Fatal("legacy submit path was accepted") + } + if taskErr.Code != "invalid_request" || taskErr.StatusCode != http.StatusBadRequest { + t.Fatalf("taskErr = %+v, want invalid_request 400", taskErr) + } +} + +func TestModelAPISeedanceFetchRejectsLegacyRoutesAfterTaskLookup(t *testing.T) { + setupRelayTaskTestDB(t) + seedRelayTask(t, &model.Task{ + TaskID: "task_modelapi", + UserId: 123, + Platform: constant.TaskPlatform(strconv.Itoa(constant.ChannelTypeModelAPISeedance)), + ChannelId: 111, + Status: model.TaskStatusSuccess, + FailReason: "https://api.modelapi.co/v1/tasks/upstream-secret", + Properties: model.Properties{OriginModelName: "doubao-seedance-2-5-260628", UpstreamModelName: "modelapi-secret-model"}, + PrivateData: model.TaskPrivateData{ + UpstreamTaskID: "upstream-secret", + ResultURL: "https://flatkey.example/v1/videos/task_modelapi/content", + }, + Data: []byte(`{"task_id":"upstream-secret","status":"succeeded"}`), + }) + + for _, path := range []string{ + "/v1/video/generations/task_modelapi", + "/v1/generation/tasks/task_modelapi", + } { + t.Run(path, func(t *testing.T) { + _, taskErr := fetchTaskByPath(t, path, "task_modelapi") + if taskErr == nil { + t.Fatal("legacy fetch path was accepted") + } + if taskErr.StatusCode != http.StatusBadRequest { + t.Fatalf("status = %d, want 400", taskErr.StatusCode) + } + text := taskErr.Message + if taskErr.Error != nil { + text += " " + taskErr.Error.Error() + } + for _, marker := range []string{"ModelAPI", "modelapi", "api.modelapi.co", "upstream-secret", "private/result.mp4", "modelapi-secret-model"} { + if strings.Contains(text, marker) { + t.Fatalf("legacy fetch error leaked %q in %q", marker, text) + } + } + }) + } + + t.Run("allows standard OpenAI video fetch", func(t *testing.T) { + body, taskErr := fetchTaskByPath(t, "/v1/videos/task_modelapi", "task_modelapi") + if taskErr != nil { + t.Fatalf("standard fetch rejected: %+v", taskErr) + } + var got dto.OpenAIVideo + if err := common.Unmarshal(body, &got); err != nil { + t.Fatalf("unmarshal OpenAI video response: %v", err) + } + if got.ID != "task_modelapi" || got.Metadata["url"] != "https://flatkey.example/v1/videos/task_modelapi/content" { + t.Fatalf("OpenAI video response = %+v", got) + } + for _, marker := range []string{"ModelAPI", "api.modelapi.co", "upstream-secret", "private/result.mp4", "modelapi-secret-model"} { + if strings.Contains(string(body), marker) { + t.Fatalf("standard fetch response leaked %q in %s", marker, body) + } + } + }) +} + +func TestNonModelAPISeedanceLegacyFetchRoutesRemainUsable(t *testing.T) { + setupRelayTaskTestDB(t) + seedRelayTask(t, &model.Task{ + TaskID: "task_doubao", + UserId: 123, + Platform: constant.TaskPlatform(strconv.Itoa(constant.ChannelTypeDoubaoVideo)), + ChannelId: constant.ChannelTypeDoubaoVideo, + Status: model.TaskStatusSuccess, + PrivateData: model.TaskPrivateData{ + ResultURL: "https://example.com/result.mp4", + }, + }) + + body, taskErr := fetchTaskByPath(t, "/v1/generation/tasks/task_doubao", "task_doubao") + if taskErr != nil { + t.Fatalf("non-111 generation task fetch rejected: %+v", taskErr) + } + var generation gotGenerationTaskResponse + if err := common.Unmarshal(body, &generation); err != nil { + t.Fatalf("unmarshal generation response: %v", err) + } + if generation.ID != "task_doubao" || generation.Status != "succeeded" { + t.Fatalf("generation response = %+v", generation) + } + + body, taskErr = fetchTaskByPath(t, "/v1/video/generations/task_doubao", "task_doubao") + if taskErr != nil { + t.Fatalf("non-111 generic fetch rejected: %+v", taskErr) + } + var generic dto.TaskResponse[any] + if err := common.Unmarshal(body, &generic); err != nil { + t.Fatalf("unmarshal generic response: %v", err) + } + if generic.Code != "success" || generic.Data == nil { + t.Fatalf("generic response = %+v", generic) + } +} + +type gotGenerationTaskResponse struct { + ID string `json:"id"` + Status string `json:"status"` +} + +func setupRelayTaskTestDB(t *testing.T) { + t.Helper() + oldDB := model.DB + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + model.DB = db + t.Cleanup(func() { + model.DB = oldDB + sqlDB, err := db.DB() + if err == nil { + _ = sqlDB.Close() + } + }) + if err := db.AutoMigrate(&model.Task{}, &model.Channel{}); err != nil { + t.Fatalf("migrate tasks: %v", err) + } +} + +func seedRelayTask(t *testing.T, task *model.Task) { + t.Helper() + if err := model.DB.Create(task).Error; err != nil { + t.Fatalf("seed task %s: %v", task.TaskID, err) + } +} + +func fetchTaskByPath(t *testing.T, path string, taskID string) ([]byte, *dto.TaskError) { + t.Helper() + gin.SetMode(gin.TestMode) + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest(http.MethodGet, path, nil) + c.Set("id", 123) + c.Params = gin.Params{{Key: "task_id", Value: taskID}, {Key: "id", Value: taskID}} + + taskErr := RelayTaskFetch(c, relayconstant.RelayModeVideoFetchByID) + return w.Body.Bytes(), taskErr +} diff --git a/service/asset_model_scope.go b/service/asset_model_scope.go index 2659f2771e1..1320675faf0 100644 --- a/service/asset_model_scope.go +++ b/service/asset_model_scope.go @@ -148,7 +148,10 @@ func assetModelHasReusableAssetCapability(modelName string) bool { suffix := strings.TrimLeft(normalized[index+len("seedance"):], "-_./ ") return strings.HasPrefix(suffix, "2.0") || strings.HasPrefix(suffix, "2-0") || - strings.HasPrefix(suffix, "2_0") + strings.HasPrefix(suffix, "2_0") || + strings.HasPrefix(suffix, "2.5") || + strings.HasPrefix(suffix, "2-5") || + strings.HasPrefix(suffix, "2_5") } offset = index + len("seedance") } diff --git a/service/asset_model_status.go b/service/asset_model_status.go index 954aae666bb..c99566f9949 100644 --- a/service/asset_model_status.go +++ b/service/asset_model_status.go @@ -92,7 +92,7 @@ func ReconcileAssetForScope(ctx context.Context, userID int, publicID string, sc return nil, err } result.Status = strictStatus - result.AvailableModels, err = availableAssetModelsForScope(scope, rows, targets, activeBindingKeys) + result.AvailableModels, err = availableAssetModelsForScope(*asset, scope, rows, targets, activeBindingKeys) if err != nil { return nil, err } @@ -154,8 +154,16 @@ func projectAssetStatusForScope(asset model.Asset, scope AssetModelScope, rows [ case model.AssetModelReadinessStatusActive: if row.TargetGeneration != target.Generation || row.ChannelId != target.ChannelId || - row.BindingScope != target.BindingScope || - !activeBindingKeys.has(target) { + row.BindingScope != target.BindingScope { + return model.AssetStatusProcessing, nil + } + if assetModelTargetUsesSourceURL(target) { + if !assetModelSourceRecoverable(asset) { + return model.AssetStatusProcessing, nil + } + continue + } + if !activeBindingKeys.has(target) { return model.AssetStatusProcessing, nil } default: @@ -255,7 +263,7 @@ func loadActiveAssetBindingKeysForTargets(assetID int64, targets map[string]mode return active, nil } -func availableAssetModelsForScope(scope AssetModelScope, rows []model.AssetModelReadiness, targets map[string]model.AssetModelCoverageTarget, activeBindingKeys activeAssetBindingKeySet) ([]string, error) { +func availableAssetModelsForScope(asset model.Asset, scope AssetModelScope, rows []model.AssetModelReadiness, targets map[string]model.AssetModelCoverageTarget, activeBindingKeys activeAssetBindingKeySet) ([]string, error) { modelNames := normalizedStrings(scope.ModelNames) if len(modelNames) == 0 { return []string{}, nil @@ -286,6 +294,12 @@ func availableAssetModelsForScope(scope AssetModelScope, rows []model.AssetModel row.BindingScope != target.BindingScope { continue } + if assetModelTargetUsesSourceURL(target) { + if assetModelSourceRecoverable(asset) { + available = append(available, modelName) + } + continue + } if activeBindingKeys.has(target) { available = append(available, modelName) } @@ -293,6 +307,24 @@ func availableAssetModelsForScope(scope AssetModelScope, rows []model.AssetModel return normalizedStrings(available), nil } +func assetModelTargetUsesSourceURL(target model.AssetModelCoverageTarget) bool { + return strings.TrimSpace(target.BindingScope) == assetModelSourceURLBindingScopeModelAPI +} + +func assetModelSourceRecoverable(asset model.Asset) bool { + return assetReferenceSourceURLRecoverable(assetReferenceAsset{ + ID: asset.Id, + PublicID: asset.PublicId, + AssetType: asset.AssetType, + Status: asset.Status, + SourceStatus: asset.SourceStatus, + StorageBackend: asset.StorageBackend, + StorageBucket: asset.StorageBucket, + ObjectKey: asset.ObjectKey, + SourceExpiresAt: asset.SourceExpiresAt, + }) +} + func markAssetModelReadinessFailed(assetID int64, scopeKey, modelName string, now int64) error { return model.DB.Model(&model.AssetModelReadiness{}). Where("asset_id = ? AND scope_key = ? AND model_name = ?", assetID, strings.TrimSpace(scopeKey), strings.TrimSpace(modelName)). diff --git a/service/asset_model_target.go b/service/asset_model_target.go index 29496ad7a94..00ae78f6be5 100644 --- a/service/asset_model_target.go +++ b/service/asset_model_target.go @@ -24,6 +24,12 @@ type AssetModelTargetCandidate struct { CredentialIndex int } +const assetModelSourceURLBindingScopeModelAPI = "source-url:modelapi" + +func AssetModelChannelUsesSourceURL(channelType int) bool { + return channelType == constant.ChannelTypeModelAPISeedance +} + func AssetModelTargetCandidates(scope AssetModelScope, modelName string) ([]AssetModelTargetCandidate, error) { modelName = strings.TrimSpace(modelName) if modelName == "" || len(scope.Groups) == 0 { @@ -70,6 +76,11 @@ func assetModelChannelEligible(scope AssetModelScope, channel *model.Channel) bo if scope.SpecificChannelID > 0 && channel.Id != scope.SpecificChannelID { return false } + if AssetModelChannelUsesSourceURL(channel.Type) { + return channelCanConsumeAssetType(channel, "Image") || + channelCanConsumeAssetType(channel, "Video") || + channelCanConsumeAssetType(channel, "Audio") + } if _, ok := assetMaterializerForChannel(channel.Type); !ok { return false } @@ -84,6 +95,17 @@ func assetModelCandidatesForChannel(channel *model.Channel, modelName string) [] if !ok { return nil } + if AssetModelChannelUsesSourceURL(channel.Type) { + return []AssetModelTargetCandidate{{ + ChannelID: channel.Id, + ChannelType: channel.Type, + Priority: channel.GetPriority(), + Weight: channel.GetWeight(), + MappedModel: mappedModel, + BindingScope: assetModelSourceURLBindingScopeModelAPI, + CredentialIndex: -1, + }} + } if channel.Type != constant.ChannelTypeTechMobiVideo { scope, err := assetBindingScope(channel.Type, AssetMaterializeOptions{Model: mappedModel}) if err != nil { diff --git a/service/asset_model_worker.go b/service/asset_model_worker.go index 4c1fa963c15..352d5185c36 100644 --- a/service/asset_model_worker.go +++ b/service/asset_model_worker.go @@ -257,6 +257,15 @@ func PrepareAssetModelReadiness(ctx context.Context, row model.AssetModelReadine if !eligible { return scheduleAssetModelReadinessRetry(row, owner, nowUnix, AssetMaterializeErrorProcessing, 0) } + if AssetModelChannelUsesSourceURL(channel.Type) && target.BindingScope == assetModelSourceURLBindingScopeModelAPI { + if channel.Status != common.ChannelStatusEnabled || !assetModelTargetMatchesCurrentChannel(*target, channel) { + return scheduleAssetModelReadinessRetry(row, owner, nowUnix, AssetMaterializeErrorProcessing, 0) + } + if !assetModelSourceRecoverable(asset) { + return finishAssetModelReadinessFailed(row, owner, nowUnix, "source_unavailable") + } + return finishAssetModelReadinessActive(row, owner, nowUnix) + } options, _, err := ResolveAssetModelTargetOptions(*target, channel) if err != nil { return scheduleAssetModelReadinessRetry(row, owner, nowUnix, AssetMaterializeErrorProcessing, 0) diff --git a/service/asset_reference.go b/service/asset_reference.go index 3232c6039d9..b969400ab00 100644 --- a/service/asset_reference.go +++ b/service/asset_reference.go @@ -183,6 +183,12 @@ func (s AssetReferenceSet) targetReadinessForChannel(channel *model.Channel, ori if !ok || !assetModelReadinessMatchesTarget(row, *s.target) || row.AssetId != asset.ID || row.Status != model.AssetModelReadinessStatusActive { return AssetReadinessRecoverable, true } + if AssetModelChannelUsesSourceURL(channel.Type) && s.target.BindingScope == assetModelSourceURLBindingScopeModelAPI { + if assetReferenceSourceURLRecoverable(asset) { + continue + } + return AssetReadinessRecoverable, true + } if _, ok := activeAssetReferenceBindingForScope(asset.Bindings, channel.Id, s.target.BindingScope); !ok { return AssetReadinessRecoverable, true } @@ -733,6 +739,10 @@ func assetReferenceSourceRecoverable(asset assetReferenceAsset) bool { return asset.SourceExpiresAt > assetNow().Unix() } +func assetReferenceSourceURLRecoverable(asset assetReferenceAsset) bool { + return asset.Status == model.AssetStatusActive && assetReferenceSourceRecoverable(asset) +} + func assetReferenceSourceExpired(asset assetReferenceAsset) bool { if asset.SourceStatus == model.AssetSourceStatusExpired || asset.Status == model.AssetStatusExpired { return true @@ -747,6 +757,8 @@ func channelCanConsumeAssetType(channel *model.Channel, assetType string) bool { switch channel.Type { case constant.ChannelTypeBytePlus: return assetType == "Image" || assetType == "Video" || assetType == "Audio" + case constant.ChannelTypeModelAPISeedance: + return assetType == "Image" || assetType == "Video" || assetType == "Audio" case constant.ChannelTypeBlockRunSeedance, constant.ChannelTypeBlockRunVideo, constant.ChannelTypeSora, constant.ChannelTypeTechMobiVideo, constant.ChannelTypeXaiGrokVideo: return assetType == "Image" || assetType == "Video" default: diff --git a/service/asset_source_url.go b/service/asset_source_url.go new file mode 100644 index 00000000000..f4e13122380 --- /dev/null +++ b/service/asset_source_url.go @@ -0,0 +1,156 @@ +package service + +import ( + "context" + "errors" + "net/url" + "strings" + "time" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/model" + "gorm.io/gorm" +) + +var ErrAssetSourceURLUnavailable = errors.New("asset source url unavailable") + +const modelAPIAssetSourceURLTTL = 12 * time.Hour + +func ResolveAssetSourceURLRewriteMap(ctx context.Context, userID int, references AssetReferenceSet, channel *model.Channel, originModel string) (map[string]string, error) { + if ctx == nil { + ctx = context.Background() + } + if !references.HasReferences() { + return nil, nil + } + if !references.strictCoverage || references.target == nil || strings.TrimSpace(references.scope.ScopeKey) == "" { + return nil, ErrAssetSourceURLUnavailable + } + if channel == nil || !AssetModelChannelUsesSourceURL(channel.Type) || channel.Status != common.ChannelStatusEnabled { + return nil, ErrAssetSourceURLUnavailable + } + originModel = strings.TrimSpace(originModel) + if originModel == "" { + return nil, ErrAssetSourceURLUnavailable + } + mappedModel, ok := assetReferenceMappedModel(channel.GetModelMapping(), originModel) + if !ok || strings.TrimSpace(mappedModel) == "" { + return nil, ErrAssetSourceURLUnavailable + } + + distinct, publicIDs, err := distinctAssetSourceURLReferences(references.references) + if err != nil { + return nil, err + } + items, err := model.GetAssetsWithBindingsByPublicIDsForUser(userID, publicIDs) + if err != nil { + return nil, err + } + assets := make(map[string]model.Asset, len(items)) + for publicID, item := range items { + assets[publicID] = item.Asset + } + + signTargets := make([]model.Asset, 0, len(distinct)) + for _, reference := range distinct { + asset, ok := assets[reference.PublicID] + if !ok { + return nil, ErrAssetSourceURLUnavailable + } + if asset.AssetType != reference.ExpectedAssetType || !channelCanConsumeAssetType(channel, asset.AssetType) { + return nil, ErrAssetSourceURLUnavailable + } + if !assetModelSourceRecoverable(asset) { + return nil, ErrAssetSourceURLUnavailable + } + target, row, err := resolveAssetSourceURLTargetReadiness(asset, references.scope, *references.target, originModel) + if err != nil { + return nil, err + } + if target.ChannelId != channel.Id || + strings.TrimSpace(target.MappedModel) != strings.TrimSpace(mappedModel) || + !assetModelTargetUsesSourceURL(target) || + !assetModelReadinessMatchesTarget(row, target) || + row.AssetId != asset.Id || + row.Status != model.AssetModelReadinessStatusActive { + return nil, ErrAssetSourceURLUnavailable + } + signTargets = append(signTargets, asset) + } + + signingConfig := CurrentAssetStorageConfig() + signingConfig.SignedURLTTL = modelAPIAssetSourceURLTTL + rewrite := make(map[string]string, len(signTargets)) + for _, asset := range signTargets { + signed, err := SignAssetSourceURL(ctx, asset, signingConfig) + if err != nil { + return nil, err + } + parsed, err := url.Parse(signed) + if err != nil || parsed.Scheme != "https" { + return nil, ErrAssetSourceURLUnavailable + } + rewrite["asset://"+asset.PublicId] = signed + } + return rewrite, nil +} + +func distinctAssetSourceURLReferences(references []assetReference) ([]assetReference, []string, error) { + distinct := make([]assetReference, 0, len(references)) + publicIDs := make([]string, 0, len(references)) + seen := make(map[string]assetReference, len(references)) + for _, reference := range references { + reference.PublicID = strings.TrimSpace(reference.PublicID) + reference.ExpectedAssetType = strings.TrimSpace(reference.ExpectedAssetType) + if reference.PublicID == "" || reference.ExpectedAssetType == "" { + return nil, nil, ErrAssetSourceURLUnavailable + } + if existing, ok := seen[reference.PublicID]; ok { + if existing.ExpectedAssetType != reference.ExpectedAssetType { + return nil, nil, ErrAssetSourceURLUnavailable + } + continue + } + seen[reference.PublicID] = reference + distinct = append(distinct, reference) + publicIDs = append(publicIDs, reference.PublicID) + } + return distinct, publicIDs, nil +} + +func resolveAssetSourceURLTargetReadiness(asset model.Asset, scope AssetModelScope, selectedTarget model.AssetModelCoverageTarget, originModel string) (model.AssetModelCoverageTarget, model.AssetModelReadiness, error) { + if strings.TrimSpace(selectedTarget.ScopeKey) != strings.TrimSpace(scope.ScopeKey) || + strings.TrimSpace(selectedTarget.ModelName) != strings.TrimSpace(originModel) || + selectedTarget.Status != model.AssetModelTargetStatusActive || + selectedTarget.BindingScope != assetModelSourceURLBindingScopeModelAPI || + !activeAssetModelTargetForScope(scope, selectedTarget) { + return model.AssetModelCoverageTarget{}, model.AssetModelReadiness{}, ErrAssetSourceURLUnavailable + } + var row model.AssetModelReadiness + err := model.DB.Where("asset_id = ? AND scope_key = ? AND model_name = ? AND status = ?", asset.Id, selectedTarget.ScopeKey, selectedTarget.ModelName, model.AssetModelReadinessStatusActive). + Order("updated_at DESC, id DESC"). + First(&row).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return model.AssetModelCoverageTarget{}, model.AssetModelReadiness{}, ErrAssetSourceURLUnavailable + } + if err != nil { + return model.AssetModelCoverageTarget{}, model.AssetModelReadiness{}, err + } + target, err := model.GetAssetModelCoverageTarget(selectedTarget.ScopeKey, selectedTarget.ModelName) + if errors.Is(err, gorm.ErrRecordNotFound) { + return model.AssetModelCoverageTarget{}, model.AssetModelReadiness{}, ErrAssetSourceURLUnavailable + } + if err != nil { + return model.AssetModelCoverageTarget{}, model.AssetModelReadiness{}, err + } + if target == nil || + target.Status != model.AssetModelTargetStatusActive || + target.Generation != selectedTarget.Generation || + target.ChannelId != selectedTarget.ChannelId || + target.MappedModel != selectedTarget.MappedModel || + target.BindingScope != selectedTarget.BindingScope || + target.CredentialIndex != selectedTarget.CredentialIndex { + return model.AssetModelCoverageTarget{}, model.AssetModelReadiness{}, ErrAssetSourceURLUnavailable + } + return *target, row, nil +} diff --git a/service/task_polling.go b/service/task_polling.go index 39504858a7c..c4b4dae30cc 100644 --- a/service/task_polling.go +++ b/service/task_polling.go @@ -9,6 +9,7 @@ import ( "regexp" "sort" "strings" + "sync/atomic" "time" "github.com/QuantumNous/new-api/common" @@ -52,9 +53,23 @@ type perCallTaskBillingAdjuster interface { // 打破 service -> relay -> relay/channel -> service 的循环依赖。 var GetTaskAdaptorFunc func(platform constant.TaskPlatform) TaskPollingAdaptor +var archiveVideoResultForChannel = ArchiveVideoResultForChannel var archiveTechMobiVideoResult = ArchiveVideoResult +var archiveModelAPIVideoResult = func(ctx context.Context, publicTaskID, upstreamURL, proxy string) (*model.VideoResult, error) { + return archiveVideoResultForChannel(ctx, "modelapi", publicTaskID, upstreamURL, proxy) +} + +var taskVideoResultArchiveLeaseHeartbeatInterval time.Duration + +const ( + modelAPIPollingRequestTimeout = 30 * time.Second + modelAPIPollingResponseMaxBytes = 1 << 20 + taskVideoResultArchiveLeaseMinHeartbeatInterval = 5 * time.Second +) + +var archivedVideoLogURLPattern = regexp.MustCompile(`https?://[^\s"'<>]+`) -var techMobiLogURLPattern = regexp.MustCompile(`https?://[^\s"'<>]+`) +var errTaskVideoResultArchiveLeaseLost = errors.New("video_result_archive_lease_lost") // sweepTimedOutTasks 在主轮询之前独立清理超时任务。 // 每次最多处理 100 条,剩余的下个周期继续处理。 @@ -315,14 +330,14 @@ func taskNeedsUpdate(oldTask *model.Task, newTask dto.SunoDataResponse) bool { func UpdateVideoTasks(ctx context.Context, platform constant.TaskPlatform, taskChannelM map[int][]string, taskM map[string]*model.Task) error { for channelId, taskIds := range taskChannelM { if err := updateVideoTasks(ctx, platform, channelId, taskIds, taskM); err != nil { - logger.LogError(ctx, fmt.Sprintf("Channel #%d failed to update video async tasks: %s", channelId, err.Error())) + logger.LogError(ctx, fmt.Sprintf("Failed to update video async tasks: %s", err.Error())) } } return nil } func updateVideoTasks(ctx context.Context, platform constant.TaskPlatform, channelId int, taskIds []string, taskM map[string]*model.Task) error { - logger.LogInfo(ctx, fmt.Sprintf("Channel #%d pending video tasks: %d", channelId, len(taskIds))) + logger.LogInfo(ctx, fmt.Sprintf("Pending video tasks: %d", len(taskIds))) if len(taskIds) == 0 { return nil } @@ -357,7 +372,7 @@ func updateVideoTasks(ctx context.Context, platform constant.TaskPlatform, chann adaptor.Init(info) for _, taskId := range taskIds { if err := updateVideoSingleTask(ctx, adaptor, cacheGetChannel, taskId, taskM); err != nil { - logger.LogError(ctx, fmt.Sprintf("Failed to update video task %s: %s", taskId, err.Error())) + logger.LogError(ctx, fmt.Sprintf("Failed to update video task: %s", err.Error())) } // sleep 1 second between each task to avoid hitting rate limits of upstream platforms time.Sleep(1 * time.Second) @@ -365,6 +380,80 @@ func updateVideoTasks(ctx context.Context, platform constant.TaskPlatform, chann return nil } +func startTaskVideoResultArchiveLeaseHeartbeat(ctx context.Context, cancel context.CancelFunc, taskID string, fromStatus model.TaskStatus, owner string, initialLeaseExpiresAt int64, leaseTTL time.Duration) func() (int64, error) { + interval := effectiveTaskVideoResultArchiveLeaseHeartbeatInterval(leaseTTL) + stop := make(chan struct{}) + done := make(chan error, 1) + var lost atomic.Bool + var latestExpiry atomic.Int64 + latestExpiry.Store(initialLeaseExpiresAt) + + go func() { + ticker := time.NewTicker(interval) + defer ticker.Stop() + for { + select { + case <-stop: + done <- nil + return + case <-ctx.Done(): + done <- ctx.Err() + return + case <-ticker.C: + now, err := model.GetDBTimestampWithContext(ctx) + if err != nil { + lost.Store(true) + cancel() + done <- errTaskVideoResultArchiveLeaseLost + return + } + expected := latestExpiry.Load() + nextExpiry := now + int64(leaseTTL.Seconds()) + won, err := model.RenewTaskVideoResultArchiveLease(taskID, fromStatus, owner, expected, now, nextExpiry) + if err != nil || !won { + lost.Store(true) + cancel() + done <- errTaskVideoResultArchiveLeaseLost + return + } + latestExpiry.Store(nextExpiry) + } + } + }() + + return func() (int64, error) { + if lost.Load() { + return latestExpiry.Load(), <-done + } + close(stop) + err := <-done + if errors.Is(err, context.Canceled) { + return latestExpiry.Load(), nil + } + return latestExpiry.Load(), err + } +} + +func effectiveTaskVideoResultArchiveLeaseHeartbeatInterval(leaseTTL time.Duration) time.Duration { + if taskVideoResultArchiveLeaseHeartbeatInterval > 0 { + return taskVideoResultArchiveLeaseHeartbeatInterval + } + interval := leaseTTL / 3 + if interval > time.Minute { + interval = time.Minute + } + if interval < taskVideoResultArchiveLeaseMinHeartbeatInterval { + interval = taskVideoResultArchiveLeaseMinHeartbeatInterval + } + if leaseTTL > 0 && interval >= leaseTTL { + interval = leaseTTL / 2 + } + if interval <= 0 { + interval = time.Second + } + return interval +} + func updateVideoSingleTask(ctx context.Context, adaptor TaskPollingAdaptor, ch *model.Channel, taskId string, taskM map[string]*model.Task) error { baseURL := constant.ChannelBaseURLs[ch.Type] if ch.GetBaseURL() != "" { @@ -376,8 +465,11 @@ func updateVideoSingleTask(ctx context.Context, adaptor TaskPollingAdaptor, ch * task := taskM[taskId] if task == nil { - logger.LogError(ctx, fmt.Sprintf("Task %s not found in taskM", taskId)) - return fmt.Errorf("task %s not found", taskId) + logger.LogError(ctx, "Task not found in taskM") + return errors.New("task not found") + } + if ch.Type == constant.ChannelTypeModelAPISeedance && strings.TrimSpace(proxy) != "" { + return archivedVideoPollingPhaseError(task.TaskID, "fetch") } key := ch.Key @@ -385,21 +477,34 @@ func updateVideoSingleTask(ctx context.Context, adaptor TaskPollingAdaptor, ch * if privateData.Key != "" { key = privateData.Key } - resp, err := FetchTaskWithContext(ctx, adaptor, baseURL, key, map[string]any{ - "task_id": task.GetUpstreamTaskID(), + upstreamTaskID := task.GetUpstreamTaskID() + pollingCtx := ctx + var cancelPolling context.CancelFunc + if ch.Type == constant.ChannelTypeModelAPISeedance { + pollingCtx, cancelPolling = context.WithTimeout(ctx, modelAPIPollingRequestTimeout) + defer cancelPolling() + } + resp, err := FetchTaskWithContext(pollingCtx, adaptor, baseURL, key, map[string]any{ + "task_id": upstreamTaskID, "action": task.Action, }, proxy) if err != nil { - return fmt.Errorf("fetchTask failed for task %s: %w", taskId, err) + if VideoResultChannelLabel(ch.Type) != "" { + return archivedVideoPollingPhaseError(task.TaskID, "fetch") + } + return fmt.Errorf("fetchTask failed for task %s: %w", task.TaskID, err) } defer resp.Body.Close() - responseBody, err := io.ReadAll(resp.Body) + responseBody, err := readVideoPollingResponseBody(pollingCtx, ch.Type, resp.Body) if err != nil { - return fmt.Errorf("readAll failed for task %s: %w", taskId, err) + if VideoResultChannelLabel(ch.Type) != "" { + return archivedVideoPollingPhaseError(task.TaskID, "read") + } + return fmt.Errorf("readAll failed for task %s: %w", task.TaskID, err) } - if ch.Type == constant.ChannelTypeTechMobiVideo { - logger.LogDebug(ctx, "updateVideoSingleTask response received: task_id=%s upstream_task_id=%s phase=fetched bytes=%d", task.TaskID, task.GetUpstreamTaskID(), len(responseBody)) + if VideoResultChannelLabel(ch.Type) != "" { + logger.LogDebug(ctx, "updateVideoSingleTask response received: task_id=%s phase=fetched bytes=%d", task.TaskID, len(responseBody)) } else { logger.LogDebug(ctx, "updateVideoSingleTask response: %s", responseBody) } @@ -409,9 +514,9 @@ func updateVideoSingleTask(ctx context.Context, adaptor TaskPollingAdaptor, ch * taskResult := &relaycommon.TaskInfo{} // try parse as New API response format var responseItems dto.TaskResponse[model.Task] - if err = common.Unmarshal(responseBody, &responseItems); err == nil && responseItems.IsSuccess() { - if ch.Type == constant.ChannelTypeTechMobiVideo { - logger.LogDebug(ctx, "updateVideoSingleTask parsed as new api response format: task_id=%s upstream_task_id=%s phase=parsed status=%s", task.TaskID, task.GetUpstreamTaskID(), responseItems.Data.Status) + if ch.Type != constant.ChannelTypeModelAPISeedance && common.Unmarshal(responseBody, &responseItems) == nil && responseItems.IsSuccess() { + if VideoResultChannelLabel(ch.Type) != "" { + logger.LogDebug(ctx, "updateVideoSingleTask parsed as new api response format: task_id=%s phase=parsed status=%s", task.TaskID, archivedVideoPollingStatus(string(responseItems.Data.Status))) } else { logger.LogDebug(ctx, "updateVideoSingleTask parsed as new api response format: %+v", responseItems) } @@ -423,13 +528,16 @@ func updateVideoSingleTask(ctx context.Context, adaptor TaskPollingAdaptor, ch * taskResult.Reason = t.FailReason task.Data = t.Data } else if taskResult, err = adaptor.ParseTaskResult(responseBody); err != nil { - return fmt.Errorf("parseTaskResult failed for task %s: %w", taskId, err) + if VideoResultChannelLabel(ch.Type) != "" { + return archivedVideoPollingPhaseError(task.TaskID, "parse") + } + return fmt.Errorf("parseTaskResult failed for task %s: %w", task.TaskID, err) } task.Data = redactVideoResponseForChannel(ch.Type, responseBody) - if ch.Type == constant.ChannelTypeTechMobiVideo { - logger.LogDebug(ctx, "updateVideoSingleTask task result parsed: task_id=%s upstream_task_id=%s phase=parsed status=%s progress=%s", task.TaskID, task.GetUpstreamTaskID(), taskResult.Status, taskResult.Progress) + if VideoResultChannelLabel(ch.Type) != "" { + logger.LogDebug(ctx, "updateVideoSingleTask task result parsed: task_id=%s phase=parsed status=%s", task.TaskID, archivedVideoPollingStatus(taskResult.Status)) } else { logger.LogDebug(ctx, "updateVideoSingleTask taskResult: %+v", taskResult) } @@ -451,26 +559,103 @@ func updateVideoSingleTask(ctx context.Context, adaptor TaskPollingAdaptor, ch * taskResult = relaycommon.FailTaskInfo("upstream returned error") } else { // unknown error format, log original response - if ch.Type == constant.ChannelTypeTechMobiVideo { - logger.LogError(ctx, fmt.Sprintf("Task %s returned empty status with unrecognized error format", taskId)) + if VideoResultChannelLabel(ch.Type) != "" { + logger.LogError(ctx, fmt.Sprintf("Task %s returned empty status with unrecognized error format", task.TaskID)) } else { - logger.LogError(ctx, fmt.Sprintf("Task %s returned empty status with unrecognized error format, response: %s", taskId, string(responseBody))) + logger.LogError(ctx, fmt.Sprintf("Task %s returned empty status with unrecognized error format, response: %s", task.TaskID, string(responseBody))) } taskResult = relaycommon.FailTaskInfo("upstream returned unrecognized message") } } } - if returnSourceURL && taskResult.Status == model.TaskStatusSuccess && snap.Status != model.TaskStatusSuccess && strings.TrimSpace(taskResult.Url) == "" { - return fmt.Errorf("techmobi task %s missing source URL", task.TaskID) + archiveChannelLabel := VideoResultChannelLabel(ch.Type) + if (returnSourceURL || (archiveChannelLabel != "" && !returnSourceURL)) && + taskResult.Status == model.TaskStatusSuccess && snap.Status != model.TaskStatusSuccess && strings.TrimSpace(taskResult.Url) == "" { + return fmt.Errorf("task %s missing source URL: phase=source status=%s", task.TaskID, archivedVideoPollingStatus(taskResult.Status)) + } + + var ( + archiveLeaseOwner string + archiveLeaseExpiresAt int64 + archiveLeaseClaimed bool + stopArchiveLeaseHeartbeat func() (int64, error) + cancelArchiveLeaseContext context.CancelFunc + ) + releaseArchiveLease := func() { + if !archiveLeaseClaimed { + return + } + cleanupCtx, cancelCleanup := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second) + defer cancelCleanup() + releaseNow, dbErr := model.GetDBTimestampWithContext(cleanupCtx) + if dbErr != nil { + logger.LogError(ctx, fmt.Sprintf("GetDBTimestampWithContext failed before archive lease release for task %s: %s", task.TaskID, dbErr.Error())) + return + } + released, releaseErr := model.ReleaseTaskVideoResultArchiveLeaseWithContext(cleanupCtx, task.TaskID, snap.Status, archiveLeaseOwner, archiveLeaseExpiresAt, releaseNow) + if releaseErr != nil { + logger.LogError(ctx, fmt.Sprintf("ReleaseTaskVideoResultArchiveLease failed for task %s: %s", task.TaskID, releaseErr.Error())) + return + } + if !released { + logger.LogWarn(ctx, fmt.Sprintf("Task %s archive lease was already moved, skip release", task.TaskID)) + } } - if ch.Type == constant.ChannelTypeTechMobiVideo && !returnSourceURL && taskResult.Status == model.TaskStatusSuccess && snap.Status != model.TaskStatusSuccess { + if archiveChannelLabel != "" && !returnSourceURL && taskResult.Status == model.TaskStatusSuccess && snap.Status != model.TaskStatusSuccess { if task.PrivateData.VideoResult == nil { - videoResult, archiveErr := archiveTechMobiVideoResult(ctx, task.TaskID, taskResult.Url, proxy) + var ( + videoResult *model.VideoResult + archiveErr error + ) + archiveCtx := ctx + if ch.Type == constant.ChannelTypeModelAPISeedance { + dbNow, dbErr := model.GetDBTimestampWithContext(ctx) + if dbErr != nil { + logger.LogError(ctx, fmt.Sprintf("GetDBTimestampWithContext failed before archive lease for task %s: %s", task.TaskID, dbErr.Error())) + return archivedVideoPollingPhaseError(task.TaskID, "archive") + } + archiveLeaseOwner = common.GetUUID() + archiveLeaseTTL := CurrentVideoResultStorageConfig().FetchTimeout + time.Minute + archiveLeaseExpiresAt = dbNow + int64(archiveLeaseTTL.Seconds()) + claimed, claimErr := model.ClaimTaskVideoResultArchiveLease(task.TaskID, snap.Status, archiveLeaseOwner, dbNow, archiveLeaseExpiresAt) + if claimErr != nil { + logger.LogError(ctx, fmt.Sprintf("ClaimTaskVideoResultArchiveLease failed for task %s: %s", task.TaskID, claimErr.Error())) + return archivedVideoPollingPhaseError(task.TaskID, "archive") + } + if !claimed { + logger.LogWarn(ctx, fmt.Sprintf("Task %s archive lease already claimed or finalized, skip archive", task.TaskID)) + return nil + } + archiveLeaseClaimed = true + archiveCtx, cancelArchiveLeaseContext = context.WithCancel(ctx) + stopArchiveLeaseHeartbeat = startTaskVideoResultArchiveLeaseHeartbeat(archiveCtx, cancelArchiveLeaseContext, task.TaskID, snap.Status, archiveLeaseOwner, archiveLeaseExpiresAt, archiveLeaseTTL) + } + switch ch.Type { + case constant.ChannelTypeTechMobiVideo: + videoResult, archiveErr = archiveTechMobiVideoResult(ctx, task.TaskID, taskResult.Url, proxy) + case constant.ChannelTypeModelAPISeedance: + videoResult, archiveErr = archiveModelAPIVideoResult(archiveCtx, task.TaskID, taskResult.Url, proxy) + default: + videoResult, archiveErr = archiveVideoResultForChannel(ctx, archiveChannelLabel, task.TaskID, taskResult.Url, proxy) + } + if stopArchiveLeaseHeartbeat != nil { + latestExpiry, heartbeatErr := stopArchiveLeaseHeartbeat() + archiveLeaseExpiresAt = latestExpiry + if cancelArchiveLeaseContext != nil { + cancelArchiveLeaseContext() + } + if heartbeatErr != nil && archiveErr == nil { + archiveErr = heartbeatErr + } + } if archiveErr != nil { - perfmetrics.RecordVideoResultArchiveRetry("techmobi", "archive_failure") - return fmt.Errorf("archive techmobi video result failed for task %s: %s", task.TaskID, sanitizeVideoResultArchiveError(archiveErr)) + if archiveLeaseClaimed { + releaseArchiveLease() + } + perfmetrics.RecordVideoResultArchiveRetry(archiveChannelLabel, "archive_failure") + return fmt.Errorf("video archive failed for task %s: phase=archive status=%s: %s", task.TaskID, archivedVideoPollingStatus(taskResult.Status), sanitizeVideoResultArchiveError(archiveErr)) } task.PrivateData.VideoResult = videoResult } @@ -520,8 +705,8 @@ func updateVideoSingleTask(ctx context.Context, adaptor TaskPollingAdaptor, ch * } shouldSettle = true case model.TaskStatusFailure: - if ch.Type == constant.ChannelTypeTechMobiVideo { - logger.LogInfo(ctx, fmt.Sprintf("TechMobi task failed: task_id=%s channel_id=%d status=%s reason=%s", task.TaskID, ch.Id, taskResult.Status, sanitizeTechMobiLogText(taskResult.Reason))) + if VideoResultChannelLabel(ch.Type) != "" { + logger.LogInfo(ctx, fmt.Sprintf("Archived video task failed: task_id=%s status=%s", task.TaskID, taskResult.Status)) } else { logger.LogJson(ctx, fmt.Sprintf("Task %s failed", taskId), task) } @@ -531,15 +716,20 @@ func updateVideoSingleTask(ctx context.Context, adaptor TaskPollingAdaptor, ch * task.FinishTime = now } task.FailReason = taskResult.Reason - if ch.Type == constant.ChannelTypeTechMobiVideo { - task.FailReason = sanitizeTechMobiLogText(task.FailReason) + if VideoResultChannelLabel(ch.Type) != "" { + task.FailReason = sanitizeArchivedVideoLogText(ch.Type, task.FailReason) + logger.LogInfo(ctx, fmt.Sprintf("Task %s failed: status=%s", task.TaskID, task.Status)) + } else { + logger.LogInfo(ctx, fmt.Sprintf("Task %s failed: %s", task.TaskID, task.FailReason)) } - logger.LogInfo(ctx, fmt.Sprintf("Task %s failed: %s", task.TaskID, task.FailReason)) taskResult.Progress = taskcommon.ProgressComplete if quota != 0 { shouldRefund = true } default: + if VideoResultChannelLabel(ch.Type) != "" { + return fmt.Errorf("unknown task status for task %s: phase=status status=%s", task.TaskID, archivedVideoPollingStatus(taskResult.Status)) + } return fmt.Errorf("unknown task status %s for task %s", taskResult.Status, task.TaskID) } if taskResult.Progress != "" { @@ -548,7 +738,24 @@ func updateVideoSingleTask(ctx context.Context, adaptor TaskPollingAdaptor, ch * isDone := task.Status == model.TaskStatusSuccess || task.Status == model.TaskStatusFailure if isDone && snap.Status != task.Status { - won, err := task.UpdateWithStatus(snap.Status) + var won bool + var err error + if archiveLeaseClaimed { + dbNow, dbErr := model.GetDBTimestampWithContext(ctx) + if dbErr != nil { + logger.LogError(ctx, fmt.Sprintf("GetDBTimestampWithContext failed before archive finalize for task %s: %s", task.TaskID, dbErr.Error())) + releaseArchiveLease() + shouldRefund = false + shouldSettle = false + } else { + won, err = task.UpdateWithStatusAndVideoResultArchiveLease(snap.Status, archiveLeaseOwner, archiveLeaseExpiresAt, dbNow) + if err != nil || !won { + releaseArchiveLease() + } + } + } else { + won, err = task.UpdateWithStatus(snap.Status) + } if err != nil { logger.LogError(ctx, fmt.Sprintf("UpdateWithStatus failed for task %s: %s", task.TaskID, err.Error())) shouldRefund = false @@ -609,17 +816,24 @@ func redactVideoResponseBody(body []byte) []byte { func redactVideoResponseForChannel(channelType int, body []byte) []byte { redacted := redactVideoResponseBody(body) if channelType == constant.ChannelTypeTechMobiVideo { - return redactTechMobiVideoResponseBody(redacted) + return redactArchivedVideoResponseBody(redacted, false) + } + if channelType == constant.ChannelTypeModelAPISeedance { + return redactArchivedVideoResponseBody(redacted, true) } return redacted } func redactTechMobiVideoResponseBody(body []byte) []byte { + return redactArchivedVideoResponseBody(body, false) +} + +func redactArchivedVideoResponseBody(body []byte, scrubBrand bool) []byte { var value any if err := common.Unmarshal(body, &value); err != nil { return body } - redacted := redactTechMobiVideoValue(value) + redacted := redactArchivedVideoValue(value, scrubBrand) b, err := common.Marshal(redacted) if err != nil { return body @@ -627,34 +841,47 @@ func redactTechMobiVideoResponseBody(body []byte) []byte { return b } -func redactTechMobiVideoValue(v any) any { +func redactArchivedVideoValue(v any, scrubBrand bool) any { switch value := v.(type) { case map[string]any: for key, child := range value { - if isTechMobiVideoURLKey(key) { - value[key] = redactTechMobiVideoURLValue(child) + if scrubBrand && isArchivedVideoPrivateIdentifierKey(key) { + delete(value, key) + continue + } + if isArchivedVideoURLKey(key) { + value[key] = redactArchivedVideoURLValue(child, scrubBrand) continue } - value[key] = redactTechMobiVideoValue(child) + value[key] = redactArchivedVideoValue(child, scrubBrand) } return value case []any: for i, child := range value { - value[i] = redactTechMobiVideoValue(child) + value[i] = redactArchivedVideoValue(child, scrubBrand) } return value case string: - return redactTechMobiURLs(value) + return sanitizeArchivedVideoString(value, scrubBrand) default: return value } } -func redactTechMobiVideoURLValue(v any) any { - return redactTechMobiVideoValue(v) +func isArchivedVideoPrivateIdentifierKey(key string) bool { + normalized := strings.ToLower(strings.ReplaceAll(key, "_", "")) + return normalized == "id" || normalized == "taskid" +} + +func redactArchivedVideoURLValue(v any, scrubBrand bool) any { + return redactArchivedVideoValue(v, scrubBrand) } func isTechMobiVideoURLKey(key string) bool { + return isArchivedVideoURLKey(key) +} + +func isArchivedVideoURLKey(key string) bool { normalized := strings.ToLower(strings.ReplaceAll(key, "_", "")) switch normalized { case "url", "videourl", "downloadurl", "fileurl", "objecturl", "remoteurl": @@ -689,15 +916,69 @@ func sanitizeVideoResultArchiveError(err error) string { return "archive unavailable" } -func sanitizeTechMobiLogText(text string) string { +func archivedVideoPollingPhaseError(taskID, phase string) error { + return fmt.Errorf("task %s polling failed: phase=%s", taskID, phase) +} + +func archivedVideoPollingStatus(status string) string { + switch model.TaskStatus(status) { + case model.TaskStatusSubmitted, model.TaskStatusQueued, model.TaskStatusInProgress, model.TaskStatusSuccess, model.TaskStatusFailure: + return status + default: + return "unknown" + } +} + +func readVideoPollingResponseBody(ctx context.Context, channelType int, body io.Reader) ([]byte, error) { + if channelType != constant.ChannelTypeModelAPISeedance { + return io.ReadAll(body) + } + select { + case <-ctx.Done(): + return nil, ctx.Err() + default: + } + responseBody, err := io.ReadAll(io.LimitReader(body, modelAPIPollingResponseMaxBytes+1)) + if err != nil { + return nil, err + } + select { + case <-ctx.Done(): + return nil, ctx.Err() + default: + } + if len(responseBody) > modelAPIPollingResponseMaxBytes { + return nil, errors.New("polling response too large") + } + return responseBody, nil +} + +func sanitizeArchivedVideoLogText(channelType int, text string) string { if strings.TrimSpace(text) == "" { return "" } - return taskcommon.ScrubBrandedText(redactTechMobiURLs(text)) + scrubBrand := channelType == constant.ChannelTypeModelAPISeedance + return sanitizeArchivedVideoString(text, scrubBrand) +} + +func sanitizeTechMobiLogText(text string) string { + return sanitizeArchivedVideoLogText(constant.ChannelTypeTechMobiVideo, text) +} + +func sanitizeArchivedVideoString(text string, scrubBrand bool) string { + redacted := redactArchivedVideoURLs(text) + if scrubBrand { + return taskcommon.ScrubBrandedText(redacted) + } + return redacted } func redactTechMobiURLs(text string) string { - return techMobiLogURLPattern.ReplaceAllStringFunc(text, func(match string) string { + return redactArchivedVideoURLs(text) +} + +func redactArchivedVideoURLs(text string) string { + return archivedVideoLogURLPattern.ReplaceAllStringFunc(text, func(match string) string { trimmed := strings.TrimRight(match, ",.;:)") return "[redacted]" + match[len(trimmed):] }) diff --git a/service/task_polling_video_result_test.go b/service/task_polling_video_result_test.go index b08d18ce4f9..38ed322af72 100644 --- a/service/task_polling_video_result_test.go +++ b/service/task_polling_video_result_test.go @@ -7,6 +7,8 @@ import ( "errors" "io" "net/http" + "net/url" + "strings" "testing" "time" @@ -259,6 +261,7 @@ func TestUpdateVideoSingleTaskArchiveErrorDoesNotFinalizeOrSettle(t *testing.T) seedUser(t, 902, 1000) seedToken(t, 912, 902, "sk-techmobi-archive-error", 500) task := newTechMobiPollingTask(t, 902, 932, 100, 912) + task.Progress = "ModelAPI https://api.modelapi.co/v1/tasks/upstream-secret-id" ch := newTechMobiPollingChannel("") adaptor := &fakeVideoPollingAdaptor{ responseBody: techMobiArchiveResponseBody(), @@ -276,7 +279,14 @@ func TestUpdateVideoSingleTaskArchiveErrorDoesNotFinalizeOrSettle(t *testing.T) err := updateVideoSingleTask(ctx, adaptor, ch, task.GetUpstreamTaskID(), techMobiTaskMap(task)) require.Error(t, err) - require.Contains(t, err.Error(), "archive techmobi video result failed") + require.Contains(t, err.Error(), "video archive failed for task") + require.Contains(t, err.Error(), "phase=archive") + require.Contains(t, err.Error(), "status=SUCCESS") + require.Contains(t, err.Error(), "archive unavailable") + require.NotContains(t, err.Error(), "https://") + require.NotContains(t, err.Error(), "api.modelapi.co") + require.NotContains(t, strings.ToLower(err.Error()), "modelapi") + require.NotContains(t, err.Error(), "upstream-secret-id") require.NotContains(t, err.Error(), "secret.example") require.Equal(t, 0, adaptor.adjustCalls) @@ -292,6 +302,661 @@ func TestUpdateVideoSingleTaskArchiveErrorDoesNotFinalizeOrSettle(t *testing.T) require.Contains(t, text, `newapi_video_result_archive_retry_total{channel="techmobi",reason="archive_failure"} 1`) } +func TestUpdateVideoSingleTaskModelAPIRejectsProxyBeforeFetchOrArchive(t *testing.T) { + truncate(t) + restoreArchiveHookForPollingTest(t) + ctx := context.Background() + + seedUser(t, 910, 1000) + seedToken(t, 920, 910, "sk-modelapi-archive-success", 500) + task := newModelAPIPollingTaskWithID(t, "task_proxy_fail_closed", 910, 940, 100, 920) + ch := newModelAPIPollingChannel("http://proxy.internal:8080") + adaptor := &fakeVideoPollingAdaptor{ + responseBody: modelAPIArchiveResponseBody(), + taskResult: &relaycommon.TaskInfo{ + TaskID: "upstream-modelapi-success", + Status: model.TaskStatusSuccess, + Url: "https://secret.example/video.mp4?token=secret", + Progress: "100%", + TotalTokens: 40, + }, + actualQuota: 40, + } + var archiveCalls int + archiveModelAPIVideoResult = func(_ context.Context, publicTaskID, upstreamURL, proxy string) (*model.VideoResult, error) { + archiveCalls++ + return nil, errors.New("archive must not run") + } + + err := updateVideoSingleTask(ctx, adaptor, ch, task.GetUpstreamTaskID(), modelAPITaskMap(task)) + require.Error(t, err) + require.Contains(t, err.Error(), "task_proxy_fail_closed") + require.Contains(t, err.Error(), "phase=fetch") + require.NotContains(t, err.Error(), "proxy.internal") + require.NotContains(t, strings.ToLower(err.Error()), "modelapi") + require.Equal(t, 0, archiveCalls) + require.Equal(t, 0, adaptor.fetchCalls) + require.Equal(t, 0, adaptor.parseCalls) + require.Equal(t, 0, adaptor.adjustCalls) + + var stored model.Task + require.NoError(t, model.DB.Where("task_id = ?", task.TaskID).First(&stored).Error) + require.EqualValues(t, model.TaskStatusInProgress, stored.Status) + require.Equal(t, "50%", stored.Progress) + require.Zero(t, stored.FinishTime) + require.Empty(t, stored.PrivateData.ResultURL) + require.Nil(t, stored.PrivateData.VideoResult) + require.NotContains(t, string(stored.Data), "https://") + require.NotContains(t, string(stored.Data), "api.modelapi.co") + require.NotContains(t, strings.ToLower(string(stored.Data)), "modelapi") + require.NotContains(t, string(stored.Data), "secret.example") +} + +func TestUpdateVideoSingleTaskModelAPIArchiveErrorDoesNotFinalizeOrSettle(t *testing.T) { + truncate(t) + restoreArchiveHookForPollingTest(t) + resetVideoResultMetricsForServiceTest(t) + ctx := context.Background() + + seedUser(t, 911, 1000) + seedToken(t, 921, 911, "sk-modelapi-archive-error", 500) + task := newModelAPIPollingTaskWithID(t, "task_archive_error_public", 911, 941, 100, 921) + task.Progress = "ModelAPI https://api.modelapi.co/v1/tasks/upstream-secret-id" + ch := newModelAPIPollingChannel("") + adaptor := &fakeVideoPollingAdaptor{ + responseBody: modelAPIArchiveResponseBody(), + taskResult: &relaycommon.TaskInfo{ + TaskID: "upstream-modelapi-success", + Status: model.TaskStatusSuccess, + Url: "https://secret.example/video.mp4?token=secret", + Progress: "100%", + }, + actualQuota: 40, + } + archiveModelAPIVideoResult = func(context.Context, string, string, string) (*model.VideoResult, error) { + return nil, errors.New("download failed from https://secret.example/video.mp4?token=secret") + } + + err := updateVideoSingleTask(ctx, adaptor, ch, task.GetUpstreamTaskID(), modelAPITaskMap(task)) + require.Error(t, err) + require.Contains(t, err.Error(), "video archive failed for task") + require.Contains(t, err.Error(), "phase=archive") + require.Contains(t, err.Error(), "status=SUCCESS") + require.Contains(t, err.Error(), "archive unavailable") + require.NotContains(t, err.Error(), "https://") + require.NotContains(t, err.Error(), "api.modelapi.co") + require.NotContains(t, strings.ToLower(err.Error()), "modelapi") + require.NotContains(t, err.Error(), "upstream-secret-id") + require.NotContains(t, err.Error(), "secret.example") + require.Equal(t, 0, adaptor.adjustCalls) + + var stored model.Task + require.NoError(t, model.DB.Where("task_id = ?", task.TaskID).First(&stored).Error) + require.EqualValues(t, model.TaskStatusInProgress, stored.Status) + require.Equal(t, "50%", stored.Progress) + require.Zero(t, stored.FinishTime) + require.Nil(t, stored.PrivateData.VideoResult) + require.Empty(t, stored.PrivateData.ResultURL) + require.Equal(t, 100, stored.Quota) + + text, err := perfmetrics.BuildPrometheusText(context.Background()) + require.NoError(t, err) + require.Contains(t, text, `newapi_video_result_archive_retry_total{channel="modelapi",reason="archive_failure"} 1`) +} + +func TestUpdateVideoSingleTaskModelAPIArchiveFailureNoUpstreamLeaks(t *testing.T) { + truncate(t) + restoreArchiveHookForPollingTest(t) + logs := capturePollingLogs(t) + ctx := context.Background() + + seedUser(t, 927, 1000) + seedToken(t, 927, 927, "sk-modelapi-archive-leak", 500) + task := newModelAPIPollingTaskWithID(t, "task_archive_leak", 927, 947, 100, 927) + task.Progress = "ModelAPI https://api.modelapi.co/v1/tasks/upstream-secret-id" + upstreamTaskID := task.GetUpstreamTaskID() + ch := newModelAPIPollingChannel("") + archiveError := errors.New("download failed from https://secret.example/video.mp4?token=secret") + adaptor := &fakeVideoPollingAdaptor{ + responseBody: modelAPIArchiveResponseBody(), + taskResult: &relaycommon.TaskInfo{ + TaskID: "upstream-modelapi-success", + Status: model.TaskStatusSuccess, + Url: "https://secret.example/video.mp4?token=secret", + Progress: "100%", + }, + } + archiveModelAPIVideoResult = func(context.Context, string, string, string) (*model.VideoResult, error) { + return nil, archiveError + } + + err := updateVideoSingleTask(ctx, adaptor, ch, task.GetUpstreamTaskID(), modelAPITaskMap(task)) + require.Error(t, err) + require.Contains(t, err.Error(), "phase=archive") + require.NotContains(t, err.Error(), "https://") + require.NotContains(t, err.Error(), "api.modelapi.co") + require.NotContains(t, strings.ToLower(err.Error()), "modelapi") + require.NotContains(t, err.Error(), upstreamTaskID) + require.NotContains(t, err.Error(), "upstream-secret-id") + require.NotContains(t, err.Error(), "secret.example") + + logText := logs.String() + require.NotContains(t, logText, "modelapi") + require.NotContains(t, logText, "api.modelapi.co") + require.NotContains(t, logText, "https://") + require.NotContains(t, logText, upstreamTaskID) + require.NotContains(t, logText, "upstream-secret-id") + require.NotContains(t, logText, "secret.example") +} + +func TestUpdateVideoSingleTaskModelAPIFetchErrorDoesNotLeakUpstreamDetails(t *testing.T) { + truncate(t) + restoreArchiveHookForPollingTest(t) + logs := capturePollingLogs(t) + ctx := context.Background() + + seedUser(t, 928, 1000) + seedToken(t, 928, 928, "sk-modelapi-fetch-leak", 500) + task := newModelAPIPollingTaskWithID(t, "task_fetch_error", 928, 948, 100, 928) + upstreamTaskID := task.GetUpstreamTaskID() + ch := newModelAPIPollingChannel("") + adaptor := &fakeVideoPollingAdaptor{ + fetchErr: &url.Error{ + Op: "Get", + URL: "https://api.modelapi.co/v1/tasks/upstream-secret-id", + Err: errors.New("dial upstream-secret-id failed"), + }, + } + + err := updateVideoSingleTask(ctx, adaptor, ch, task.GetUpstreamTaskID(), modelAPITaskMap(task)) + require.Error(t, err) + require.Contains(t, err.Error(), "task_fetch_error") + require.Contains(t, err.Error(), "phase=fetch") + require.NotContains(t, err.Error(), "https://") + require.NotContains(t, err.Error(), "api.modelapi.co") + require.NotContains(t, strings.ToLower(err.Error()), "modelapi") + require.NotContains(t, err.Error(), upstreamTaskID) + require.NotContains(t, err.Error(), "upstream-secret-id") + require.NotContains(t, logs.String(), "https://") + require.NotContains(t, logs.String(), "api.modelapi.co") + require.NotContains(t, strings.ToLower(logs.String()), "modelapi") + require.NotContains(t, logs.String(), upstreamTaskID) + require.NotContains(t, logs.String(), "upstream-secret-id") +} + +func TestUpdateVideoSingleTaskModelAPIReadErrorDoesNotLeakUpstreamDetails(t *testing.T) { + truncate(t) + restoreArchiveHookForPollingTest(t) + ctx := context.Background() + + seedUser(t, 929, 1000) + seedToken(t, 929, 929, "sk-modelapi-read-leak", 500) + task := newModelAPIPollingTaskWithID(t, "task_read_error", 929, 949, 100, 929) + upstreamTaskID := task.GetUpstreamTaskID() + ch := newModelAPIPollingChannel("") + adaptor := &fakeVideoPollingAdaptor{ + body: errReadCloser{err: errors.New("read https://api.modelapi.co/v1/tasks/upstream-secret-id failed")}, + } + + err := updateVideoSingleTask(ctx, adaptor, ch, task.GetUpstreamTaskID(), modelAPITaskMap(task)) + require.Error(t, err) + require.Contains(t, err.Error(), "task_read_error") + require.Contains(t, err.Error(), "phase=read") + require.NotContains(t, err.Error(), "https://") + require.NotContains(t, err.Error(), "api.modelapi.co") + require.NotContains(t, strings.ToLower(err.Error()), "modelapi") + require.NotContains(t, err.Error(), upstreamTaskID) + require.NotContains(t, err.Error(), "upstream-secret-id") +} + +func TestUpdateVideoSingleTaskModelAPIOverLimitBodyDoesNotPersistOrLeak(t *testing.T) { + truncate(t) + restoreArchiveHookForPollingTest(t) + ctx := context.Background() + + seedUser(t, 933, 1000) + seedToken(t, 933, 933, "sk-modelapi-read-limit", 500) + task := newModelAPIPollingTaskWithID(t, "task_read_limit", 933, 953, 100, 933) + ch := newModelAPIPollingChannel("") + secretBody := append(bytes.Repeat([]byte("a"), 1024*1024), []byte("https://api.modelapi.co/v1/tasks/upstream-secret-id")...) + adaptor := &fakeVideoPollingAdaptor{ + responseBody: secretBody, + taskResult: &relaycommon.TaskInfo{ + Status: model.TaskStatusSuccess, + Url: "https://secret.example/video.mp4?token=secret", + Progress: "100%", + }, + actualQuota: 40, + } + + err := updateVideoSingleTask(ctx, adaptor, ch, task.GetUpstreamTaskID(), modelAPITaskMap(task)) + require.Error(t, err) + require.Contains(t, err.Error(), "task_read_limit") + require.Contains(t, err.Error(), "phase=read") + require.NotContains(t, err.Error(), "https://") + require.NotContains(t, err.Error(), "api.modelapi.co") + require.NotContains(t, strings.ToLower(err.Error()), "modelapi") + require.Equal(t, 0, adaptor.adjustCalls) + + var stored model.Task + require.NoError(t, model.DB.Where("task_id = ?", task.TaskID).First(&stored).Error) + require.EqualValues(t, model.TaskStatusInProgress, stored.Status) + require.Equal(t, "50%", stored.Progress) + require.Zero(t, stored.FinishTime) + require.Equal(t, json.RawMessage(`{"status":"processing"}`), stored.Data) + require.Empty(t, stored.PrivateData.ResultURL) + require.Nil(t, stored.PrivateData.VideoResult) +} + +func TestUpdateVideoSingleTaskModelAPIParseErrorDoesNotLeakUpstreamDetails(t *testing.T) { + truncate(t) + restoreArchiveHookForPollingTest(t) + ctx := context.Background() + + seedUser(t, 930, 1000) + seedToken(t, 930, 930, "sk-modelapi-parse-leak", 500) + task := newModelAPIPollingTaskWithID(t, "task_parse_error", 930, 950, 100, 930) + upstreamTaskID := task.GetUpstreamTaskID() + ch := newModelAPIPollingChannel("") + adaptor := &fakeVideoPollingAdaptor{ + responseBody: []byte(`{"status":"not-new-api"}`), + parseErr: errors.New("parse ModelAPI https://api.modelapi.co/v1/tasks/upstream-secret-id failed"), + } + + err := updateVideoSingleTask(ctx, adaptor, ch, task.GetUpstreamTaskID(), modelAPITaskMap(task)) + require.Error(t, err) + require.Contains(t, err.Error(), "task_parse_error") + require.Contains(t, err.Error(), "phase=parse") + require.NotContains(t, err.Error(), "https://") + require.NotContains(t, err.Error(), "api.modelapi.co") + require.NotContains(t, strings.ToLower(err.Error()), "modelapi") + require.NotContains(t, err.Error(), upstreamTaskID) + require.NotContains(t, err.Error(), "upstream-secret-id") +} + +func TestUpdateVideoSingleTaskModelAPISkipsGenericWrapperAndRequiresAdaptorParse(t *testing.T) { + truncate(t) + restoreArchiveHookForPollingTest(t) + ctx := context.Background() + + seedUser(t, 934, 1000) + seedToken(t, 934, 934, "sk-modelapi-wrapper-bypass", 500) + task := newModelAPIPollingTaskWithID(t, "task_wrapper_bypass", 934, 954, 100, 934) + ch := newModelAPIPollingChannel("") + adaptor := &fakeVideoPollingAdaptor{ + responseBody: []byte(`{ + "code":"success", + "data":{ + "task_id":"task_wrapper_bypass", + "status":"SUCCESS", + "fail_reason":"https://secret.example/forged.mp4?token=secret", + "progress":"100%" + } + }`), + parseErr: errors.New("parse ModelAPI https://api.modelapi.co/v1/tasks/upstream-secret-id failed"), + } + var archiveCalls int + archiveModelAPIVideoResult = func(context.Context, string, string, string) (*model.VideoResult, error) { + archiveCalls++ + return &model.VideoResult{ + Bucket: "archive-bucket", + Object: "video-results/20260806/task_wrapper_bypass.mp4", + ContentType: "video/mp4", + Size: 1, + }, nil + } + + err := updateVideoSingleTask(ctx, adaptor, ch, task.GetUpstreamTaskID(), modelAPITaskMap(task)) + require.Error(t, err) + require.Equal(t, 1, adaptor.parseCalls) + require.Equal(t, 0, archiveCalls) + require.Contains(t, err.Error(), "task_wrapper_bypass") + require.Contains(t, err.Error(), "phase=parse") + require.NotContains(t, err.Error(), "https://") + require.NotContains(t, err.Error(), "api.modelapi.co") + require.NotContains(t, strings.ToLower(err.Error()), "modelapi") + require.Equal(t, 0, adaptor.adjustCalls) + + var stored model.Task + require.NoError(t, model.DB.Where("task_id = ?", task.TaskID).First(&stored).Error) + require.EqualValues(t, model.TaskStatusInProgress, stored.Status) + require.Equal(t, "50%", stored.Progress) + require.Zero(t, stored.FinishTime) + require.Empty(t, stored.PrivateData.ResultURL) + require.Nil(t, stored.PrivateData.VideoResult) +} + +func TestUpdateVideoSingleTaskModelAPIFetchAndReadUseHardDeadline(t *testing.T) { + truncate(t) + restoreArchiveHookForPollingTest(t) + ctx := context.Background() + + seedUser(t, 935, 1000) + seedToken(t, 935, 935, "sk-modelapi-deadline", 500) + task := newModelAPIPollingTaskWithID(t, "task_modelapi_deadline", 935, 955, 100, 935) + ch := newModelAPIPollingChannel("") + adaptor := &fakeVideoPollingAdaptor{ + taskResult: &relaycommon.TaskInfo{ + Status: model.TaskStatusSuccess, + Url: "https://secret.example/video.mp4?token=secret", + Progress: "100%", + }, + } + body := &deadlineAwareReadCloser{ctx: &adaptor.fetchCtx} + adaptor.body = body + + err := updateVideoSingleTask(ctx, adaptor, ch, task.GetUpstreamTaskID(), modelAPITaskMap(task)) + require.Error(t, err) + require.Contains(t, err.Error(), "task_modelapi_deadline") + require.Contains(t, err.Error(), "phase=read") + require.True(t, adaptor.fetchUsedContext) + require.NotNil(t, adaptor.fetchCtx) + deadline, ok := adaptor.fetchCtx.Deadline() + require.True(t, ok, "ModelAPI fetch must receive a hard deadline even with Background parent ctx") + require.WithinDuration(t, time.Now().Add(30*time.Second), deadline, time.Second) + require.True(t, body.sawDeadline, "ModelAPI body read must use the same deadline-bound context") + require.Equal(t, 0, adaptor.parseCalls) + require.Equal(t, 0, adaptor.adjustCalls) + + var stored model.Task + require.NoError(t, model.DB.Where("task_id = ?", task.TaskID).First(&stored).Error) + require.EqualValues(t, model.TaskStatusInProgress, stored.Status) + require.Equal(t, "50%", stored.Progress) + require.Zero(t, stored.FinishTime) + require.Empty(t, stored.PrivateData.ResultURL) + require.Nil(t, stored.PrivateData.VideoResult) +} + +func TestUpdateVideoTasksModelAPIDoesNotLogChannelID(t *testing.T) { + truncate(t) + restoreArchiveHookForPollingTest(t) + logs := capturePollingLogs(t) + ctx := context.Background() + + seedUser(t, 931, 1000) + seedToken(t, 931, 931, "sk-modelapi-channel-log", 500) + task := newModelAPIPollingTaskWithID(t, "task_channel_log", 931, 951, 100, 931) + ch := newModelAPIPollingChannel("") + ch.Id = 951 + require.NoError(t, model.DB.Create(ch).Error) + originalAdaptorFunc := GetTaskAdaptorFunc + GetTaskAdaptorFunc = func(platform constant.TaskPlatform) TaskPollingAdaptor { + require.Equal(t, constant.TaskPlatform("111"), platform) + return &fakeVideoPollingAdaptor{ + fetchErr: errors.New("fetch failed from https://api.modelapi.co/v1/tasks/upstream-secret-id"), + } + } + t.Cleanup(func() { + GetTaskAdaptorFunc = originalAdaptorFunc + }) + + require.NoError(t, UpdateVideoTasks(ctx, constant.TaskPlatform("111"), map[int][]string{ch.Id: []string{task.GetUpstreamTaskID()}}, modelAPITaskMap(task))) + + logText := logs.String() + require.NotContains(t, logText, "Channel #") + require.NotContains(t, logText, "951") + require.NotContains(t, logText, "https://") + require.NotContains(t, logText, "api.modelapi.co") + require.NotContains(t, strings.ToLower(logText), "modelapi") + require.NotContains(t, logText, task.GetUpstreamTaskID()) + require.NotContains(t, logText, "upstream-secret-id") +} + +func TestUpdateVideoSingleTaskModelAPIUnknownStatusDoesNotLeakUpstreamDetails(t *testing.T) { + truncate(t) + restoreArchiveHookForPollingTest(t) + logs := capturePollingLogs(t) + ctx := context.Background() + + seedUser(t, 932, 1000) + seedToken(t, 932, 932, "sk-modelapi-status-leak", 500) + task := newModelAPIPollingTaskWithID(t, "task_status_error", 932, 952, 100, 932) + upstreamTaskID := task.GetUpstreamTaskID() + ch := newModelAPIPollingChannel("") + adaptor := &fakeVideoPollingAdaptor{ + responseBody: []byte(`{"status":"not-new-api"}`), + taskResult: &relaycommon.TaskInfo{ + TaskID: "upstream-modelapi-success", + Status: "ModelAPI failed at https://api.modelapi.co/v1/tasks/upstream-secret-id", + Reason: "reason from https://api.modelapi.co/v1/tasks/upstream-secret-id", + Progress: "100%", + }, + } + + err := updateVideoSingleTask(ctx, adaptor, ch, task.GetUpstreamTaskID(), modelAPITaskMap(task)) + require.Error(t, err) + require.Contains(t, err.Error(), "task_status_error") + require.Contains(t, err.Error(), "phase=status") + require.Contains(t, err.Error(), "status=unknown") + require.NotContains(t, err.Error(), "https://") + require.NotContains(t, err.Error(), "api.modelapi.co") + require.NotContains(t, strings.ToLower(err.Error()), "modelapi") + require.NotContains(t, err.Error(), upstreamTaskID) + require.NotContains(t, err.Error(), "upstream-secret-id") + + logText := logs.String() + require.NotContains(t, logText, "https://") + require.NotContains(t, logText, "api.modelapi.co") + require.NotContains(t, strings.ToLower(logText), "modelapi") + require.NotContains(t, logText, upstreamTaskID) + require.NotContains(t, logText, "upstream-secret-id") +} + +func TestUpdateVideoSingleTaskModelAPIEmptySuccessURLDoesNotFinalizeOrSettle(t *testing.T) { + truncate(t) + restoreArchiveHookForPollingTest(t) + ctx := context.Background() + + seedUser(t, 912, 1000) + seedToken(t, 922, 912, "sk-modelapi-empty-url", 500) + task := newModelAPIPollingTaskWithID(t, "task_empty_url_public", 912, 942, 100, 922) + task.Progress = "ModelAPI https://api.modelapi.co/v1/tasks/upstream-secret-id" + ch := newModelAPIPollingChannel("") + adaptor := &fakeVideoPollingAdaptor{ + responseBody: modelAPIArchiveResponseBody(), + taskResult: &relaycommon.TaskInfo{ + TaskID: "upstream-modelapi-success", + Status: model.TaskStatusSuccess, + Url: " ", + Progress: "100%", + }, + actualQuota: 40, + } + archiveModelAPIVideoResult = func(context.Context, string, string, string) (*model.VideoResult, error) { + t.Fatal("archive hook must not be called when ModelAPI success URL is empty") + return nil, nil + } + + err := updateVideoSingleTask(ctx, adaptor, ch, task.GetUpstreamTaskID(), modelAPITaskMap(task)) + require.Error(t, err) + require.Contains(t, err.Error(), "missing source URL") + require.Contains(t, err.Error(), "phase=source") + require.NotContains(t, err.Error(), "https://") + require.NotContains(t, err.Error(), "api.modelapi.co") + require.NotContains(t, strings.ToLower(err.Error()), "modelapi") + require.NotContains(t, err.Error(), "upstream-secret-id") + require.Equal(t, 0, adaptor.adjustCalls) + + var stored model.Task + require.NoError(t, model.DB.Where("task_id = ?", task.TaskID).First(&stored).Error) + require.EqualValues(t, model.TaskStatusInProgress, stored.Status) + require.Equal(t, "50%", stored.Progress) + require.Zero(t, stored.FinishTime) + require.Nil(t, stored.PrivateData.VideoResult) + require.Empty(t, stored.PrivateData.ResultURL) +} + +func TestUpdateVideoSingleTaskModelAPIRedactsStoredDataAndLogs(t *testing.T) { + truncate(t) + restoreArchiveHookForPollingTest(t) + logs := capturePollingLogs(t) + ctx := context.Background() + + seedUser(t, 913, 1000) + seedToken(t, 923, 913, "sk-modelapi-redaction", 500) + task := newModelAPIPollingTaskWithID(t, "task_archive_redaction", 913, 943, 100, 923) + ch := newModelAPIPollingChannel("") + upstreamURL := "https://api.modelapi.co/private/video.mp4?token=secret" + adaptor := &fakeVideoPollingAdaptor{ + responseBody: modelAPIRedactionResponseBody(), + taskResult: &relaycommon.TaskInfo{ + TaskID: "upstream-modelapi-success", + Status: model.TaskStatusSuccess, + Url: upstreamURL, + Progress: "100%", + TotalTokens: 40, + }, + actualQuota: 40, + } + archiveModelAPIVideoResult = func(context.Context, string, string, string) (*model.VideoResult, error) { + return &model.VideoResult{ + Bucket: "archive-bucket", + Object: "video-results/20260806/task_archive_redaction.mp4", + ContentType: "video/mp4", + Size: 1, + }, nil + } + + require.NoError(t, updateVideoSingleTask(ctx, adaptor, ch, task.GetUpstreamTaskID(), modelAPITaskMap(task))) + + var stored model.Task + require.NoError(t, model.DB.Where("task_id = ?", task.TaskID).First(&stored).Error) + storedData := string(stored.Data) + require.NotContains(t, storedData, upstreamURL) + require.NotContains(t, storedData, "opaque-upstream-task-123") + require.NotContains(t, storedData, "task_id") + require.NotContains(t, storedData, "https://") + require.NotContains(t, storedData, "api.modelapi.co") + require.NotContains(t, strings.ToLower(storedData), "modelapi") + + logText := logs.String() + upstreamTaskID := "upstream-modelapi-success" + require.NotContains(t, logText, upstreamTaskID) + require.NotContains(t, logText, upstreamURL) + require.NotContains(t, logText, "https://") + require.NotContains(t, logText, "api.modelapi.co") + require.NotContains(t, logText, "channel_id=") + require.NotContains(t, logText, "reason=") + require.NotContains(t, strings.ToLower(logText), "modelapi") +} + +func TestUpdateVideoSingleTaskModelAPIFailureRedactsDBAndLogs(t *testing.T) { + truncate(t) + restoreArchiveHookForPollingTest(t) + logs := capturePollingLogs(t) + ctx := context.Background() + + seedUser(t, 914, 1000) + seedToken(t, 924, 914, "sk-modelapi-failure-redaction", 500) + task := newModelAPIPollingTaskWithID(t, "task_archive_failure", 914, 944, 100, 924) + ch := newModelAPIPollingChannel("") + adaptor := &fakeVideoPollingAdaptor{ + responseBody: modelAPIFailureResponseBody(), + taskResult: &relaycommon.TaskInfo{ + TaskID: "upstream-modelapi-success", + Status: model.TaskStatusFailure, + Reason: "ModelAPI render failed at https://api.modelapi.co/private/failure.mp4?token=secret", + Progress: "100%", + }, + } + + require.NoError(t, updateVideoSingleTask(ctx, adaptor, ch, task.GetUpstreamTaskID(), modelAPITaskMap(task))) + + var stored model.Task + require.NoError(t, model.DB.Where("task_id = ?", task.TaskID).First(&stored).Error) + require.EqualValues(t, model.TaskStatusFailure, stored.Status) + require.NotContains(t, strings.ToLower(stored.FailReason), "modelapi") + require.NotContains(t, stored.FailReason, "https://") + require.NotContains(t, stored.FailReason, "api.modelapi.co") + require.NotContains(t, strings.ToLower(string(stored.Data)), "modelapi") + require.NotContains(t, string(stored.Data), "https://") + require.NotContains(t, string(stored.Data), "api.modelapi.co") + require.NotContains(t, strings.ToLower(logs.String()), "modelapi") + require.NotContains(t, logs.String(), "https://") + require.NotContains(t, logs.String(), "api.modelapi.co") + require.NotContains(t, logs.String(), "channel_id=") + require.NotContains(t, logs.String(), "reason=") + require.NotContains(t, logs.String(), "render failed") + require.NotContains(t, logs.String(), "upstream-modelapi-success") +} + +func TestUpdateVideoSingleTaskModelAPIUnknownErrorFormatDoesNotLogRawResponse(t *testing.T) { + truncate(t) + restoreArchiveHookForPollingTest(t) + logs := capturePollingLogs(t) + ctx := context.Background() + + seedUser(t, 915, 1000) + seedToken(t, 925, 915, "sk-modelapi-unknown-redaction", 500) + task := newModelAPIPollingTaskWithID(t, "task_archive_unknown", 915, 945, 100, 925) + ch := newModelAPIPollingChannel("") + adaptor := &fakeVideoPollingAdaptor{ + responseBody: []byte(`{"unexpected":"ModelAPI raw https://api.modelapi.co/private/video.mp4?token=secret"}`), + taskResult: &relaycommon.TaskInfo{}, + } + + err := updateVideoSingleTask(ctx, adaptor, ch, task.GetUpstreamTaskID(), modelAPITaskMap(task)) + require.NoError(t, err) + require.NotContains(t, strings.ToLower(logs.String()), "modelapi") + require.NotContains(t, logs.String(), "https://") + require.NotContains(t, logs.String(), "api.modelapi.co") + require.NotContains(t, logs.String(), "upstream-modelapi-success") +} + +func TestUpdateVideoSingleTaskModelAPICASLoserDoesNotSettleTwice(t *testing.T) { + truncate(t) + restoreArchiveHookForPollingTest(t) + ctx := context.Background() + + seedUser(t, 916, 1000) + seedToken(t, 926, 916, "sk-modelapi-cas-loser", 500) + task := newModelAPIPollingTaskWithID(t, "task_modelapi_cas_loser", 916, 946, 100, 926) + var staleTask model.Task + require.NoError(t, model.DB.Where("task_id = ?", task.TaskID).First(&staleTask).Error) + + ch := newModelAPIPollingChannel("") + winnerAdaptor := &fakeVideoPollingAdaptor{ + responseBody: modelAPIArchiveResponseBody(), + taskResult: &relaycommon.TaskInfo{ + TaskID: "upstream-modelapi-success", + Status: model.TaskStatusSuccess, + Url: "https://secret.example/video.mp4?token=secret", + Progress: "100%", + TotalTokens: 40, + }, + actualQuota: 40, + } + loserAdaptor := &fakeVideoPollingAdaptor{ + responseBody: modelAPIArchiveResponseBody(), + taskResult: &relaycommon.TaskInfo{ + TaskID: "upstream-modelapi-success", + Status: model.TaskStatusSuccess, + Url: "https://secret.example/video.mp4?token=secret", + Progress: "100%", + TotalTokens: 40, + }, + actualQuota: 40, + } + archiveModelAPIVideoResult = func(context.Context, string, string, string) (*model.VideoResult, error) { + return &model.VideoResult{ + Bucket: "archive-bucket", + Object: "video-results/20260806/task_modelapi_cas_loser.mp4", + ContentType: "video/mp4", + Size: 1, + }, nil + } + + require.NoError(t, updateVideoSingleTask(ctx, winnerAdaptor, ch, task.GetUpstreamTaskID(), modelAPITaskMap(task))) + require.NoError(t, updateVideoSingleTask(ctx, loserAdaptor, ch, staleTask.GetUpstreamTaskID(), modelAPITaskMap(&staleTask))) + require.Equal(t, 1, winnerAdaptor.adjustCalls) + require.Equal(t, 0, loserAdaptor.adjustCalls) + + var stored model.Task + require.NoError(t, model.DB.Where("task_id = ?", task.TaskID).First(&stored).Error) + require.EqualValues(t, model.TaskStatusSuccess, stored.Status) + require.Equal(t, 40, stored.PrivateData.TotalTokens) +} + func TestUpdateVideoSingleTaskArchiveSkipsExistingMetadata(t *testing.T) { truncate(t) restoreArchiveHookForPollingTest(t) @@ -409,14 +1074,14 @@ func TestUpdateVideoSingleTaskArchiveFailurePayloadRedactsDBAndLogs(t *testing.T require.NotContains(t, string(stored.Data), "token=secret") var data map[string]any - require.NoError(t, json.Unmarshal(stored.Data, &data)) + require.NoError(t, common.Unmarshal(stored.Data, &data)) require.Equal(t, "failed", data["status"]) require.Equal(t, "render failed", data["reason"]) require.NotContains(t, logs.String(), "secret.example") require.NotContains(t, logs.String(), "token=secret") require.Contains(t, logs.String(), "task_archive_success") - require.Contains(t, logs.String(), "render failed") + require.NotContains(t, logs.String(), "render failed") } func TestRedactTechMobiVideoResponseBodyRemovesUpstreamURLsAndKeepsPublicFields(t *testing.T) { @@ -439,12 +1104,13 @@ func TestRedactTechMobiVideoResponseBodyRemovesUpstreamURLsAndKeepsPublicFields( }`) redacted := redactTechMobiVideoResponseBody(body) - require.True(t, json.Valid(redacted)) + var redactedValue any + require.NoError(t, common.Unmarshal(redacted, &redactedValue)) require.NotContains(t, string(redacted), "secret.example") require.NotContains(t, string(redacted), "token=secret") var got map[string]any - require.NoError(t, json.Unmarshal(redacted, &got)) + require.NoError(t, common.Unmarshal(redacted, &got)) require.Equal(t, "upstream-techmobi-123", got["id"]) require.Equal(t, "succeeded", got["status"]) require.Equal(t, "100%", got["progress"]) @@ -491,7 +1157,13 @@ func TestRedactTechMobiVideoResponseBodyHandlesTopLevelValues(t *testing.T) { func restoreArchiveHookForPollingTest(t *testing.T) { t.Helper() original := archiveTechMobiVideoResult - t.Cleanup(func() { archiveTechMobiVideoResult = original }) + originalModelAPI := archiveModelAPIVideoResult + originalForChannel := archiveVideoResultForChannel + t.Cleanup(func() { + archiveTechMobiVideoResult = original + archiveModelAPIVideoResult = originalModelAPI + archiveVideoResultForChannel = originalForChannel + }) } func capturePollingLogs(t *testing.T) *bytes.Buffer { @@ -544,6 +1216,87 @@ func newTechMobiPollingTask(t *testing.T, userID, channelID, quota, tokenID int) return task } +func newModelAPIPollingTask(t *testing.T, userID, channelID, quota, tokenID int) *model.Task { + t.Helper() + return newModelAPIPollingTaskWithID(t, "task_modelapi_success", userID, channelID, quota, tokenID) +} + +func newModelAPIPollingTaskWithID(t *testing.T, taskID string, userID, channelID, quota, tokenID int) *model.Task { + t.Helper() + task := &model.Task{ + TaskID: taskID, + UserId: userID, + ChannelId: channelID, + Quota: quota, + Status: model.TaskStatusInProgress, + Group: "default", + Progress: "50%", + Data: json.RawMessage(`{"status":"processing"}`), + CreatedAt: time.Now().Unix(), + UpdatedAt: time.Now().Unix(), + PrivateData: model.TaskPrivateData{ + UpstreamTaskID: "upstream-video-success", + BillingSource: BillingSourceWallet, + TokenId: tokenID, + BillingContext: &model.TaskBillingContext{OriginModelName: "seedance-2.5"}, + }, + Properties: model.Properties{OriginModelName: "seedance-2.5"}, + } + require.NoError(t, model.DB.Create(task).Error) + return task +} + +func newModelAPIPollingChannel(proxy string) *model.Channel { + ch := &model.Channel{ + Id: 940, + Type: constant.ChannelTypeModelAPISeedance, + Key: "sk-modelapi", + Status: 1, + } + if proxy != "" { + ch.SetSetting(dto.ChannelSettings{Proxy: proxy}) + } + return ch +} + +func modelAPIArchiveResponseBody() []byte { + return []byte(`{ + "id":"upstream-modelapi-success", + "status":"succeeded", + "result":{"assets":[{"type":"video","url":"https://secret.example/video.mp4?token=secret"}]}, + "usage":{"total_tokens":40} + }`) +} + +func modelAPIRedactionResponseBody() []byte { + return []byte(`{ + "id":"upstream-modelapi-success", + "task_id":"opaque-upstream-task-123", + "status":"succeeded", + "result":{ + "assets":[ + {"type":"video","url":"https://api.modelapi.co/private/video.mp4?token=secret"}, + {"type":"thumbnail","download_url":"https://api.modelapi.co/private/thumb.jpg?token=secret"} + ], + "message":"ModelAPI asset at https://api.modelapi.co/private/video.mp4?token=secret" + }, + "usage":{"total_tokens":40} + }`) +} + +func modelAPIFailureResponseBody() []byte { + return []byte(`{ + "id":"upstream-modelapi-success", + "status":"failed", + "reason":"ModelAPI render failed at https://api.modelapi.co/private/failure.mp4?token=secret", + "result":{"assets":[{"url":"https://api.modelapi.co/private/video.mp4?token=secret"}]} + }`) +} + +func modelAPITaskMap(task *model.Task) map[string]*model.Task { + return map[string]*model.Task{task.GetUpstreamTaskID(): task} +} + func newTechMobiPollingChannel(proxy string) *model.Channel { ch := &model.Channel{ Id: 931, @@ -588,22 +1341,47 @@ func techMobiFailureResponseBody() []byte { } type fakeVideoPollingAdaptor struct { - responseBody []byte - taskResult *relaycommon.TaskInfo - actualQuota int - adjustCalls int + responseBody []byte + taskResult *relaycommon.TaskInfo + actualQuota int + adjustCalls int + fetchErr error + parseErr error + fetchCalls int + parseCalls int + body io.ReadCloser + fetchCtx context.Context + fetchUsedContext bool } func (a *fakeVideoPollingAdaptor) Init(*relaycommon.RelayInfo) {} func (a *fakeVideoPollingAdaptor) FetchTask(string, string, map[string]any, string) (*http.Response, error) { + a.fetchCalls++ + if a.fetchErr != nil { + return nil, a.fetchErr + } + body := a.body + if body == nil { + body = io.NopCloser(bytes.NewReader(a.responseBody)) + } return &http.Response{ StatusCode: http.StatusOK, - Body: io.NopCloser(bytes.NewReader(a.responseBody)), + Body: body, }, nil } +func (a *fakeVideoPollingAdaptor) FetchTaskWithContext(ctx context.Context, baseURL string, key string, body map[string]any, proxy string) (*http.Response, error) { + a.fetchCtx = ctx + a.fetchUsedContext = true + return a.FetchTask(baseURL, key, body, proxy) +} + func (a *fakeVideoPollingAdaptor) ParseTaskResult([]byte) (*relaycommon.TaskInfo, error) { + a.parseCalls++ + if a.parseErr != nil { + return nil, a.parseErr + } return a.taskResult, nil } @@ -611,3 +1389,33 @@ func (a *fakeVideoPollingAdaptor) AdjustBillingOnComplete(*model.Task, *relaycom a.adjustCalls++ return a.actualQuota } + +type errReadCloser struct { + err error +} + +func (r errReadCloser) Read([]byte) (int, error) { + return 0, r.err +} + +func (r errReadCloser) Close() error { + return nil +} + +type deadlineAwareReadCloser struct { + ctx *context.Context + sawDeadline bool +} + +func (r *deadlineAwareReadCloser) Read([]byte) (int, error) { + if r.ctx != nil && *r.ctx != nil { + if _, ok := (*r.ctx).Deadline(); ok { + r.sawDeadline = true + } + } + return 0, errors.New("forced read error") +} + +func (r *deadlineAwareReadCloser) Close() error { + return nil +} diff --git a/service/video_result_channels.go b/service/video_result_channels.go new file mode 100644 index 00000000000..8b0a3339453 --- /dev/null +++ b/service/video_result_channels.go @@ -0,0 +1,18 @@ +package service + +import "github.com/QuantumNous/new-api/constant" + +// VideoResultChannelLabel returns the fixed metrics/archival channel label for +// channels whose completed video should be archived into GCS and re-served via +// the signed download proxy. Empty means the channel does not use the archive +// redirect path. +func VideoResultChannelLabel(channelType int) string { + switch channelType { + case constant.ChannelTypeTechMobiVideo: + return "techmobi" + case constant.ChannelTypeModelAPISeedance: + return "modelapi" + default: + return "" + } +} diff --git a/service/video_result_channels_test.go b/service/video_result_channels_test.go new file mode 100644 index 00000000000..7cd8ad2a0cf --- /dev/null +++ b/service/video_result_channels_test.go @@ -0,0 +1,17 @@ +package service + +import ( + "testing" + + "github.com/QuantumNous/new-api/constant" + "github.com/stretchr/testify/require" +) + +func TestVideoResultChannelLabel(t *testing.T) { + require.Equal(t, "techmobi", VideoResultChannelLabel(constant.ChannelTypeTechMobiVideo)) + require.Equal(t, "modelapi", VideoResultChannelLabel(constant.ChannelTypeModelAPISeedance)) + + for _, channelType := range []int{0, 1, 104, 106, 110, 112, 999} { + require.Empty(t, VideoResultChannelLabel(channelType)) + } +} diff --git a/service/video_result_storage.go b/service/video_result_storage.go index 6361189bb06..7a359e5e177 100644 --- a/service/video_result_storage.go +++ b/service/video_result_storage.go @@ -135,9 +135,13 @@ func CurrentVideoResultStorageConfig() VideoResultStorageConfig { } func ArchiveVideoResult(ctx context.Context, publicTaskID, upstreamURL, proxy string) (*model.VideoResult, error) { + return ArchiveVideoResultForChannel(ctx, "techmobi", publicTaskID, upstreamURL, proxy) +} + +func ArchiveVideoResultForChannel(ctx context.Context, channel, publicTaskID, upstreamURL, proxy string) (*model.VideoResult, error) { archiveStart := videoResultNow().UTC() recordArchive := func(outcome string, bytes int64) { - perfmetrics.RecordVideoResultArchive("techmobi", outcome, bytes, videoResultNow().UTC().Sub(archiveStart)) + perfmetrics.RecordVideoResultArchive(channel, outcome, bytes, videoResultNow().UTC().Sub(archiveStart)) } cfg := CurrentVideoResultStorageConfig() if strings.TrimSpace(cfg.Bucket) == "" { @@ -153,7 +157,23 @@ func ArchiveVideoResult(ctx context.Context, publicTaskID, upstreamURL, proxy st recordArchive("failure", 0) return nil, ErrVideoResultInvalidContent } - client, err := newVideoResultFetchHTTPClient(cfg, proxy, videoResultDirectFetchResolver, videoResultDirectFetchDialContext) + proxy = strings.TrimSpace(proxy) + var client *http.Client + if proxy != "" { + if strings.EqualFold(strings.TrimSpace(channel), "modelapi") { + recordArchive("failure", 0) + return nil, ErrVideoResultInvalidContent + } + client, err = GetHttpClientWithProxy(proxy) + if err == nil { + proxyClient := *client + proxyClient.Timeout = cfg.FetchTimeout + proxyClient.CheckRedirect = videoResultCheckRedirect + client = &proxyClient + } + } else { + client, err = newVideoResultFetchHTTPClient(cfg, videoResultDirectFetchResolver, videoResultDirectFetchDialContext) + } if err != nil { recordArchive("failure", 0) return nil, ErrVideoResultInvalidContent @@ -290,23 +310,12 @@ func completeVideoResultMetadata(result *model.VideoResult) bool { result.ExpiresAt > 0 } -func newVideoResultFetchHTTPClient(cfg VideoResultStorageConfig, proxy string, resolver assetFetchResolver, dialContext func(context.Context, string, string) (net.Conn, error)) (*http.Client, error) { - var client *http.Client - if strings.TrimSpace(proxy) == "" { - client = newAssetFetchHTTPClient(assetFetchHTTPClientConfig{ - Timeout: cfg.FetchTimeout, - Resolver: resolver, - DialContext: dialContext, - }) - } else { - baseClient, err := GetHttpClientWithProxy(proxy) - if err != nil { - return nil, err - } - cloned := *baseClient - cloned.Timeout = cfg.FetchTimeout - client = &cloned - } +func newVideoResultFetchHTTPClient(cfg VideoResultStorageConfig, resolver assetFetchResolver, dialContext func(context.Context, string, string) (net.Conn, error)) (*http.Client, error) { + client := newAssetFetchHTTPClient(assetFetchHTTPClientConfig{ + Timeout: cfg.FetchTimeout, + Resolver: resolver, + DialContext: dialContext, + }) client.CheckRedirect = videoResultCheckRedirect return client, nil } @@ -321,11 +330,11 @@ func videoResultCheckRedirect(req *http.Request, via []*http.Request) error { return nil } -func buildVideoResultObjectKey(taskID string, archiveStart time.Time) (string, error) { +func buildVideoResultObjectKey(taskID string, _ time.Time) (string, error) { if !videoResultTaskIDPattern.MatchString(taskID) { return "", ErrVideoResultInvalidTaskID } - return "video-results/" + archiveStart.UTC().Format("20060102") + "/" + taskID + ".mp4", nil + return "video-results/tasks/" + taskID + ".mp4", nil } func videoResultObjectBelongsToTask(objectKey, taskID string) bool { @@ -334,6 +343,9 @@ func videoResultObjectBelongsToTask(objectKey, taskID string) bool { return false } remainder := strings.TrimPrefix(objectKey, prefix) + if remainder == "tasks/"+taskID+".mp4" { + return true + } if len(remainder) <= 9 || remainder[8] != '/' { return false } diff --git a/service/video_result_storage_test.go b/service/video_result_storage_test.go index b9fcdc1cced..4f19be81d57 100644 --- a/service/video_result_storage_test.go +++ b/service/video_result_storage_test.go @@ -73,7 +73,11 @@ func TestVideoResultObjectKey(t *testing.T) { now := time.Date(2026, 8, 6, 23, 59, 0, 0, time.FixedZone("CST", 8*3600)) key, err := buildVideoResultObjectKey("task_Abc-123_ok", now) require.NoError(t, err) - require.Equal(t, "video-results/20260806/task_Abc-123_ok.mp4", key) + require.Equal(t, "video-results/tasks/task_Abc-123_ok.mp4", key) + + nextDayKey, err := buildVideoResultObjectKey("task_Abc-123_ok", now.Add(10*time.Hour)) + require.NoError(t, err) + require.Equal(t, key, nextDayKey) for _, taskID := range []string{"", "abc", "task_", "task_../x", "task_a/b", "../task_a", "task_ space"} { _, err := buildVideoResultObjectKey(taskID, now) @@ -115,7 +119,7 @@ func TestArchiveVideoResult(t *testing.T) { require.NoError(t, err) require.Equal(t, &model.VideoResult{ Bucket: "video-bucket", - Object: "video-results/20260806/task_archive-1.mp4", + Object: "video-results/tasks/task_archive-1.mp4", Generation: 1, ContentType: "video/mp4", Size: int64(len(payload)), @@ -123,7 +127,7 @@ func TestArchiveVideoResult(t *testing.T) { ExpiresAt: start.Add(time.Hour).Unix(), }, result) - created := store.created["video-bucket/video-results/20260806/task_archive-1.mp4"] + created := store.created["video-bucket/video-results/tasks/task_archive-1.mp4"] require.Equal(t, payload, created.body) require.Equal(t, "video/mp4", created.options.ContentType) require.Equal(t, "private, max-age=0, no-store", created.options.CacheControl) @@ -135,6 +139,30 @@ func TestArchiveVideoResult(t *testing.T) { require.Contains(t, text, `newapi_video_result_archive_bytes_total{channel="techmobi"} 16`) }) + t.Run("archives modelapi video with modelapi metric label", func(t *testing.T) { + resetVideoResultMetricsForServiceTest(t) + start := time.Date(2026, 8, 6, 1, 2, 3, 0, time.UTC) + store := newFakeVideoResultStore() + restore := installVideoResultArchiveTestHooks(t, store, start) + defer restore() + t.Setenv("VIDEO_RESULT_STORAGE_BUCKET", "video-bucket") + payload := minimalMP4Fixture() + + server := newVideoResultTestServer(t, http.StatusOK, "video/mp4", string(payload)) + defer server.Close() + + result, err := ArchiveVideoResultForChannel(context.Background(), "modelapi", "task_modelapi_archive", server.URL, "") + require.NoError(t, err) + require.Equal(t, "video-results/tasks/task_modelapi_archive.mp4", result.Object) + require.Contains(t, store.created, "video-bucket/video-results/tasks/task_modelapi_archive.mp4") + + text, err := perfmetrics.BuildPrometheusText(context.Background()) + require.NoError(t, err) + require.Contains(t, text, `newapi_video_result_archive_total{channel="modelapi",outcome="success"} 1`) + require.Contains(t, text, `newapi_video_result_archive_bytes_total{channel="modelapi"} 16`) + require.Contains(t, text, `newapi_video_result_archive_total{channel="techmobi",outcome="success"} 0`) + }) + for _, testCase := range []struct { name string taskID string @@ -466,6 +494,111 @@ func TestArchiveVideoResult(t *testing.T) { require.ErrorIs(t, err, ErrVideoResultConfig) }) + t.Run("modelapi rejects configured proxy before fetching archive source", func(t *testing.T) { + start := time.Date(2026, 8, 6, 0, 0, 0, 0, time.UTC) + store := newFakeVideoResultStore() + restore := installVideoResultArchiveTestHooks(t, store, start) + defer restore() + t.Setenv("VIDEO_RESULT_STORAGE_BUCKET", "video-bucket") + sourceHits := 0 + source := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + sourceHits++ + w.Header().Set("Content-Type", "video/mp4") + _, _ = w.Write(minimalMP4Fixture()) + })) + defer source.Close() + proxyHits := 0 + proxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + proxyHits++ + w.Header().Set("Content-Type", "video/mp4") + _, _ = w.Write(minimalMP4Fixture()) + })) + defer proxy.Close() + + _, err := ArchiveVideoResultForChannel(context.Background(), "modelapi", "task_proxy_rejected", source.URL, proxy.URL) + require.ErrorIs(t, err, ErrVideoResultInvalidContent) + require.Equal(t, 0, proxyHits) + require.Equal(t, 0, sourceHits) + require.Empty(t, store.created) + }) + + t.Run("techmobi archives through configured proxy", func(t *testing.T) { + start := time.Date(2026, 8, 6, 0, 0, 0, 0, time.UTC) + store := newFakeVideoResultStore() + restore := installVideoResultArchiveTestHooks(t, store, start) + defer restore() + t.Cleanup(ResetProxyClientCache) + t.Setenv("VIDEO_RESULT_STORAGE_BUCKET", "video-bucket") + payload := minimalMP4Fixture() + + sourceHits := 0 + source := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + sourceHits++ + require.Equal(t, http.MethodGet, r.Method) + w.Header().Set("Content-Type", "video/mp4") + _, _ = w.Write(payload) + })) + defer source.Close() + sourceURL, err := url.Parse(source.URL) + require.NoError(t, err) + + proxyHits := 0 + proxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + proxyHits++ + require.Equal(t, http.MethodGet, r.Method) + require.Equal(t, sourceURL.Host, r.URL.Host) + + outbound := r.Clone(r.Context()) + outbound.RequestURI = "" + outbound.Header.Del("Proxy-Connection") + response, err := http.DefaultTransport.RoundTrip(outbound) + require.NoError(t, err) + defer response.Body.Close() + + for key, values := range response.Header { + for _, value := range values { + w.Header().Add(key, value) + } + } + w.WriteHeader(response.StatusCode) + _, err = io.Copy(w, response.Body) + require.NoError(t, err) + })) + defer proxy.Close() + + result, err := ArchiveVideoResult(context.Background(), "task_proxy_archive", source.URL, proxy.URL) + require.NoError(t, err) + require.Equal(t, "video-results/tasks/task_proxy_archive.mp4", result.Object) + require.Equal(t, 1, proxyHits) + require.Equal(t, 1, sourceHits) + created := store.created["video-bucket/video-results/tasks/task_proxy_archive.mp4"] + require.Equal(t, payload, created.body) + }) + + t.Run("uses the same object key across archive start dates", func(t *testing.T) { + t.Setenv("VIDEO_RESULT_STORAGE_BUCKET", "video-bucket") + payload := minimalMP4Fixture() + server := newVideoResultTestServer(t, http.StatusOK, "video/mp4", string(payload)) + defer server.Close() + + storeBeforeMidnight := newFakeVideoResultStore() + restoreBefore := installVideoResultArchiveTestHooks(t, storeBeforeMidnight, time.Date(2026, 8, 6, 23, 59, 59, 0, time.UTC)) + resultBefore, err := ArchiveVideoResult(context.Background(), "task_cross_date", server.URL, "") + require.NoError(t, err) + restoreBefore() + + storeAfterMidnight := newFakeVideoResultStore() + restoreAfter := installVideoResultArchiveTestHooks(t, storeAfterMidnight, time.Date(2026, 8, 7, 0, 0, 1, 0, time.UTC)) + resultAfter, err := ArchiveVideoResult(context.Background(), "task_cross_date", server.URL, "") + require.NoError(t, err) + restoreAfter() + + require.Equal(t, "video-results/tasks/task_cross_date.mp4", resultBefore.Object) + require.Equal(t, resultBefore.Object, resultAfter.Object) + require.Contains(t, storeBeforeMidnight.created, "video-bucket/video-results/tasks/task_cross_date.mp4") + require.Contains(t, storeAfterMidnight.created, "video-bucket/video-results/tasks/task_cross_date.mp4") + }) + t.Run("reuses valid existing object after create conflict", func(t *testing.T) { resetVideoResultMetricsForServiceTest(t) start := time.Date(2026, 8, 6, 0, 0, 0, 0, time.UTC) @@ -475,7 +608,7 @@ func TestArchiveVideoResult(t *testing.T) { defer restore() t.Setenv("VIDEO_RESULT_STORAGE_BUCKET", "video-bucket") t.Setenv("VIDEO_RESULT_RETENTION_SECONDS", "7200") - key := "video-bucket/video-results/20260806/task_conflict.mp4" + key := "video-bucket/video-results/tasks/task_conflict.mp4" store.createErr = ErrVideoResultAlreadyExists store.attrs[key] = VideoResultObjectAttrs{ ContentType: "video/mp4", @@ -505,7 +638,7 @@ func TestArchiveVideoResult(t *testing.T) { restore := installVideoResultArchiveTestHooks(t, store, start) defer restore() t.Setenv("VIDEO_RESULT_STORAGE_BUCKET", "video-bucket") - key := "video-bucket/video-results/20260806/task_invalid_existing.mp4" + key := "video-bucket/video-results/tasks/task_invalid_existing.mp4" store.createErr = ErrVideoResultAlreadyExists store.attrs[key] = VideoResultObjectAttrs{ContentType: "application/octet-stream", Size: 42, Generation: 7, Created: start} @@ -577,19 +710,19 @@ func TestSignVideoResultDownload(t *testing.T) { t.Setenv("VIDEO_RESULT_SERVICE_ACCOUNT_EMAIL", "video-signer@example.iam.gserviceaccount.com") result := &model.VideoResult{ Bucket: "video-bucket", - Object: "video-results/20260806/task_signed.mp4", + Object: "video-results/tasks/task_signed.mp4", Generation: 7, ContentType: "video/mp4; charset=binary", Size: 42, ExpiresAt: now.Add(5 * time.Minute).Unix(), } - store.attrs["video-bucket/video-results/20260806/task_signed.mp4"] = VideoResultObjectAttrs{ + store.attrs["video-bucket/video-results/tasks/task_signed.mp4"] = VideoResultObjectAttrs{ ContentType: "video/mp4; charset=binary", Size: 42, Generation: 7, Created: now.Add(-time.Minute), } - store.signedURL = "https://storage.googleapis.com/video-bucket/video-results/20260806/task_signed.mp4?X-Goog-Signature=secret" + store.signedURL = "https://storage.googleapis.com/video-bucket/video-results/tasks/task_signed.mp4?X-Goog-Signature=secret" signed, err := SignVideoResultDownload(context.Background(), "task_signed", result) require.NoError(t, err) @@ -607,6 +740,36 @@ func TestSignVideoResultDownload(t *testing.T) { require.Equal(t, "video/mp4", req.QueryParameters.Get("response-content-type")) }) + t.Run("signs a historical date object path for existing archived results", func(t *testing.T) { + store := newFakeVideoResultStore() + restore := installVideoResultArchiveTestHooks(t, store, now) + defer restore() + t.Setenv("VIDEO_RESULT_STORAGE_BUCKET", "video-bucket") + t.Setenv("VIDEO_RESULT_SIGNED_URL_TTL_SECONDS", "900") + result := &model.VideoResult{ + Bucket: "video-bucket", + Object: "video-results/20260806/task_signed.mp4", + Generation: 7, + ContentType: "video/mp4", + Size: 42, + ExpiresAt: now.Add(5 * time.Minute).Unix(), + } + store.attrs["video-bucket/video-results/20260806/task_signed.mp4"] = VideoResultObjectAttrs{ + ContentType: "video/mp4", + Size: 42, + Generation: 7, + Created: now.Add(-time.Minute), + } + + signed, err := SignVideoResultDownload(context.Background(), "task_signed", result) + require.NoError(t, err) + require.Equal(t, "https://signed.example/video", signed) + require.Equal(t, "video-bucket", store.signedBucket) + require.Equal(t, "video-results/20260806/task_signed.mp4", store.signedObject) + require.Equal(t, 1, store.attrsCalls) + require.Equal(t, 1, store.signCalls) + }) + t.Run("requires attrs content type to match persisted media type", func(t *testing.T) { store := newFakeVideoResultStore() installVideoResultArchiveTestHooks(t, store, now) @@ -647,6 +810,9 @@ func TestSignVideoResultDownload(t *testing.T) { t.Run("rejects object paths not bound to the requested task before object access", func(t *testing.T) { for _, objectKey := range []string{ + "video-results/tasks/task_other.mp4", + "video-results/tasks/task_signed.webm", + "video-results/tasks/nested/task_signed.mp4", "video-results/20260806/task_other.mp4", "video-results/20260806/task_signed.webm", "video-results/2026080/task_signed.mp4", @@ -894,6 +1060,8 @@ type fakeVideoResultStore struct { attrsCalls int signCalls int signRequests []VideoResultSignedURLRequest + signedBucket string + signedObject string nextAttrs VideoResultObjectAttrs closedWithError bool validatedURLs map[string]bool @@ -949,9 +1117,11 @@ func (f *fakeVideoResultStore) Attrs(_ context.Context, bucket, objectKey string return attrs, nil } -func (f *fakeVideoResultStore) SignURL(_ context.Context, _ string, _ string, request VideoResultSignedURLRequest) (string, error) { +func (f *fakeVideoResultStore) SignURL(_ context.Context, bucket, objectKey string, request VideoResultSignedURLRequest) (string, error) { f.signCalls++ f.signRequests = append(f.signRequests, request) + f.signedBucket = bucket + f.signedObject = objectKey if f.signErr != nil { return "", f.signErr } diff --git a/setting/ratio_setting/model_ratio.go b/setting/ratio_setting/model_ratio.go index 508918a2e9e..fd9b530eb42 100644 --- a/setting/ratio_setting/model_ratio.go +++ b/setting/ratio_setting/model_ratio.go @@ -324,6 +324,9 @@ var defaultModelPrice = map[string]float64{ "veo-3.0-fast-generate-001": 0.15, "veo-3.1-generate-preview": 0.4, "veo-3.1-fast-generate-preview": 0.15, + // ModelAPI Seedance 2.5 calculation base; the adaptor converts each + // request/task snapshot into a complete billable_units multiplier. + "doubao-seedance-2-5-260628": 0.14, // MiniMax H3 international 768P base rate ($0.08/s). // The task adaptor applies the 1.625 multiplier for 2K output. "MiniMax-H3": 0.08, diff --git a/setting/ratio_setting/modelapi_seedance_price_test.go b/setting/ratio_setting/modelapi_seedance_price_test.go new file mode 100644 index 00000000000..f7d50162df5 --- /dev/null +++ b/setting/ratio_setting/modelapi_seedance_price_test.go @@ -0,0 +1,12 @@ +package ratio_setting + +import "testing" + +func TestModelAPISeedanceDefaultPrice(t *testing.T) { + const model = "doubao-seedance-2-5-260628" + + got := GetDefaultModelPriceMap()[model] + if got != 0.14 { + t.Fatalf("default model price for %s = %v, want 0.14", model, got) + } +} diff --git a/web/classic/src/components/table/channels/modals/EditChannelModal.jsx b/web/classic/src/components/table/channels/modals/EditChannelModal.jsx index 82d14eb1654..b02656a4ce3 100644 --- a/web/classic/src/components/table/channels/modals/EditChannelModal.jsx +++ b/web/classic/src/components/table/channels/modals/EditChannelModal.jsx @@ -27,7 +27,10 @@ import { verifyJSON, } from '../../../../helpers'; import { useIsMobile } from '../../../../hooks/common/useIsMobile'; -import { CHANNEL_OPTIONS, MODEL_FETCHABLE_CHANNEL_TYPES } from '../../../../constants'; +import { + CHANNEL_OPTIONS, + MODEL_FETCHABLE_CHANNEL_TYPES, +} from '../../../../constants'; import { SideSheet, Space, @@ -155,6 +158,8 @@ function type2secretPrompt(type) { return '按照如下格式输入: AccessKey|SecretAccessKey'; case 57: return '请输入 JSON 格式的 OAuth 凭据(必须包含 access_token 和 account_id)'; + case 111: + return 'API key from the provider'; default: return '请输入渠道对应的鉴权密钥'; } @@ -300,7 +305,8 @@ const EditChannelModal = (props) => { [inputs.upstream_model_update_last_detected_models], ); const upstreamDetectedModelsPreview = useMemo( - () => upstreamDetectedModels.slice(0, UPSTREAM_DETECTED_MODEL_PREVIEW_LIMIT), + () => + upstreamDetectedModels.slice(0, UPSTREAM_DETECTED_MODEL_PREVIEW_LIMIT), [upstreamDetectedModels], ); const upstreamDetectedModelsOmittedCount = @@ -333,9 +339,7 @@ const EditChannelModal = (props) => { return { tagLabel: t('不更改'), tagColor: 'grey', - preview: t( - '此项可选,用于覆盖请求参数。不支持覆盖 stream 参数', - ), + preview: t('此项可选,用于覆盖请求参数。不支持覆盖 stream 参数'), }; } if (!verifyJSON(raw)) { @@ -1336,12 +1340,15 @@ const EditChannelModal = (props) => { } else { formApiRef.current?.setValues(getInitValues()); try { - navigator?.clipboard?.readText()?.then((text) => { - const parsed = parseChannelConnectionString(text); - if (parsed) { - setClipboardConfig(parsed); - } - }).catch(() => {}); + navigator?.clipboard + ?.readText() + ?.then((text) => { + const parsed = parseChannelConnectionString(text); + if (parsed) { + setClipboardConfig(parsed); + } + }) + .catch(() => {}); } catch {} } fetchModelGroups(); @@ -1349,7 +1356,8 @@ const EditChannelModal = (props) => { setUseManualInput(false); // 编辑模式下恢复用户偏好,创建模式一律折叠 setAdvancedSettingsOpen( - isEdit && localStorage.getItem(ADVANCED_SETTINGS_EXPANDED_KEY) === 'true' + isEdit && + localStorage.getItem(ADVANCED_SETTINGS_EXPANDED_KEY) === 'true', ); } else { // 统一的模态框关闭重置逻辑 @@ -1752,7 +1760,7 @@ const EditChannelModal = (props) => { const channelExtraSettings = { force_format: localInputs.force_format || false, thinking_to_content: localInputs.thinking_to_content || false, - proxy: localInputs.proxy || '', + proxy: localInputs.type === 111 ? '' : localInputs.proxy || '', pass_through_body_enabled: localInputs.pass_through_body_enabled || false, system_prompt: localInputs.system_prompt || '', system_prompt_override: localInputs.system_prompt_override || false, @@ -2219,92 +2227,97 @@ const EditChannelModal = (props) => {
{/* Upstream Model Management Section */} {MODEL_FETCHABLE_CHANNEL_TYPES.has(inputs.type) && ( -
- - {t('上游模型管理')} - +
+ + {t('上游模型管理')} + - - handleChannelOtherSettingsChange( - 'upstream_model_update_check_enabled', - value, - ) - } - extraText={t( - '开启后由后端定时任务检测该渠道上游模型变化', - )} - /> - - handleChannelOtherSettingsChange('upstream_model_update_auto_sync_enabled', value) - } - extraText={t('开启后检测到新增模型会自动加入当前渠道模型列表')} - /> - - handleInputChange( - 'upstream_model_update_ignored_models', - value, - ) - } - showClear - /> -
- {t('上次检测时间')}:  - {formatUnixTime( - inputs.upstream_model_update_last_check_time, - )} -
-
- {t('上次检测到可加入模型')}:  - {upstreamDetectedModels.length === 0 ? ( - t('暂无') - ) : ( - <> - - {upstreamDetectedModels.join(', ')} -
- } - > - - {upstreamDetectedModelsPreview.join(', ')} + + handleChannelOtherSettingsChange( + 'upstream_model_update_check_enabled', + value, + ) + } + extraText={t( + '开启后由后端定时任务检测该渠道上游模型变化', + )} + /> + + handleChannelOtherSettingsChange( + 'upstream_model_update_auto_sync_enabled', + value, + ) + } + extraText={t( + '开启后检测到新增模型会自动加入当前渠道模型列表', + )} + /> + + handleInputChange( + 'upstream_model_update_ignored_models', + value, + ) + } + showClear + /> +
+ {t('上次检测时间')}:  + {formatUnixTime( + inputs.upstream_model_update_last_check_time, + )} +
+
+ {t('上次检测到可加入模型')}:  + {upstreamDetectedModels.length === 0 ? ( + t('暂无') + ) : ( + <> + + {upstreamDetectedModels.join(', ')} +
+ } + > + + {upstreamDetectedModelsPreview.join(', ')} + + + + {upstreamDetectedModelsOmittedCount > 0 + ? t('(共 {{total}} 个,省略 {{omit}} 个)', { + total: upstreamDetectedModels.length, + omit: upstreamDetectedModelsOmittedCount, + }) + : t('(共 {{total}} 个)', { + total: upstreamDetectedModels.length, + })} - - - {upstreamDetectedModelsOmittedCount > 0 - ? t('(共 {{total}} 个,省略 {{omit}} 个)', { - total: upstreamDetectedModels.length, - omit: upstreamDetectedModelsOmittedCount, - }) - : t('(共 {{total}} 个)', { - total: upstreamDetectedModels.length, - })} - - - )} + + )} +
-
)} {/* Request Config Section */} @@ -2315,7 +2328,9 @@ const EditChannelModal = (props) => {
- {t('参数覆盖')} + + {t('参数覆盖')} +
@@ -2475,7 +2525,9 @@ const EditChannelModal = (props) => { label={t('渠道优先级')} placeholder={t('渠道优先级')} min={0} - onNumberChange={(value) => handleInputChange('priority', value)} + onNumberChange={(value) => + handleInputChange('priority', value) + } style={{ width: '100%' }} /> @@ -2485,7 +2537,9 @@ const EditChannelModal = (props) => { label={t('渠道权重')} placeholder={t('渠道权重')} min={0} - onNumberChange={(value) => handleInputChange('weight', value)} + onNumberChange={(value) => + handleInputChange('weight', value) + } style={{ width: '100%' }} /> @@ -2504,7 +2558,7 @@ const EditChannelModal = (props) => { } style={{ width: '100%' }} extraText={t( - '单个渠道允许的最大进行中请求数,0 表示不限制' + '单个渠道允许的最大进行中请求数,0 表示不限制', )} /> @@ -2515,10 +2569,68 @@ const EditChannelModal = (props) => {
{t('字段透传控制')}
- handleChannelOtherSettingsChange('allow_service_tier', value)} extraText={t('service_tier 字段用于指定服务层级,允许透传可能导致实际计费高于预期。默认关闭以避免额外费用')} /> - handleChannelOtherSettingsChange('disable_store', value)} extraText={t('store 字段用于授权 OpenAI 存储请求数据以评估和优化产品。默认关闭,开启后可能导致 Codex 无法正常使用')} /> - handleChannelOtherSettingsChange('allow_safety_identifier', value)} extraText={t('safety_identifier 字段用于帮助 OpenAI 识别可能违反使用政策的应用程序用户。默认关闭以保护用户隐私')} /> - handleChannelOtherSettingsChange('allow_include_obfuscation', value)} extraText={t('include_obfuscation 用于控制 Responses 流混淆字段。默认关闭以避免客户端关闭该安全保护')} /> + + handleChannelOtherSettingsChange( + 'allow_service_tier', + value, + ) + } + extraText={t( + 'service_tier 字段用于指定服务层级,允许透传可能导致实际计费高于预期。默认关闭以避免额外费用', + )} + /> + + handleChannelOtherSettingsChange( + 'disable_store', + value, + ) + } + extraText={t( + 'store 字段用于授权 OpenAI 存储请求数据以评估和优化产品。默认关闭,开启后可能导致 Codex 无法正常使用', + )} + /> + + handleChannelOtherSettingsChange( + 'allow_safety_identifier', + value, + ) + } + extraText={t( + 'safety_identifier 字段用于帮助 OpenAI 识别可能违反使用政策的应用程序用户。默认关闭以保护用户隐私', + )} + /> + + handleChannelOtherSettingsChange( + 'allow_include_obfuscation', + value, + ) + } + extraText={t( + 'include_obfuscation 用于控制 Responses 流混淆字段。默认关闭以避免客户端关闭该安全保护', + )} + /> )} @@ -2527,9 +2639,48 @@ const EditChannelModal = (props) => {
{t('字段透传控制')}
- handleChannelOtherSettingsChange('allow_service_tier', value)} extraText={t('service_tier 字段用于指定服务层级,允许透传可能导致实际计费高于预期。默认关闭以避免额外费用')} /> - handleChannelOtherSettingsChange('allow_inference_geo', value)} extraText={t('inference_geo 字段用于控制 Claude 数据驻留推理区域。默认关闭以避免未经授权透传地域信息')} /> - handleChannelOtherSettingsChange('allow_speed', value)} extraText={t('speed 字段用于控制 Claude 推理速度模式。默认关闭以避免意外切换到 fast 模式')} /> + + handleChannelOtherSettingsChange( + 'allow_service_tier', + value, + ) + } + extraText={t( + 'service_tier 字段用于指定服务层级,允许透传可能导致实际计费高于预期。默认关闭以避免额外费用', + )} + /> + + handleChannelOtherSettingsChange( + 'allow_inference_geo', + value, + ) + } + extraText={t( + 'inference_geo 字段用于控制 Claude 数据驻留推理区域。默认关闭以避免未经授权透传地域信息', + )} + /> + + handleChannelOtherSettingsChange('allow_speed', value) + } + extraText={t( + 'speed 字段用于控制 Claude 推理速度模式。默认关闭以避免意外切换到 fast 模式', + )} + /> )}
@@ -2541,458 +2692,396 @@ const EditChannelModal = (props) => { {inputs.type === 14 && ( - handleChannelOtherSettingsChange('claude_beta_query', value)} extraText={t('开启后,该渠道请求 Claude 时将强制追加 ?beta=true(无需客户端手动传参)')} /> + + handleChannelOtherSettingsChange( + 'claude_beta_query', + value, + ) + } + extraText={t( + '开启后,该渠道请求 Claude 时将强制追加 ?beta=true(无需客户端手动传参)', + )} + /> )} {inputs.type === 1 && ( - handleChannelSettingsChange('force_format', value)} extraText={t('强制将响应格式化为 OpenAI 标准格式(只适用于OpenAI渠道类型)')} /> + + handleChannelSettingsChange('force_format', value) + } + extraText={t( + '强制将响应格式化为 OpenAI 标准格式(只适用于OpenAI渠道类型)', + )} + /> )} - handleChannelSettingsChange('thinking_to_content', value)} extraText={t('将 reasoning_content 转换为 标签拼接到内容中')} /> - handleChannelSettingsChange('pass_through_body_enabled', value)} extraText={t('启用请求体透传功能')} /> + + handleChannelSettingsChange('thinking_to_content', value) + } + extraText={t( + '将 reasoning_content 转换为 标签拼接到内容中', + )} + /> + + handleChannelSettingsChange( + 'pass_through_body_enabled', + value, + ) + } + extraText={t('启用请求体透传功能')} + /> - handleChannelSettingsChange('proxy', value)} showClear extraText={t('用于配置网络代理,支持 socks5 协议')} /> + {inputs.type !== 111 && ( + + handleChannelSettingsChange('proxy', value) + } + showClear + extraText={t('用于配置网络代理,支持 socks5 协议')} + /> + )} - handleChannelSettingsChange('system_prompt', value)} autosize showClear extraText={t('用户优先:如果用户在请求中指定了系统提示词,将优先使用用户的设置')} /> - handleChannelSettingsChange('system_prompt_override', value)} extraText={t('如果用户请求中包含系统提示词,则使用此设置拼接到用户的系统提示词前面')} /> + + handleChannelSettingsChange('system_prompt', value) + } + autosize + showClear + extraText={t( + '用户优先:如果用户在请求中指定了系统提示词,将优先使用用户的设置', + )} + /> + + handleChannelSettingsChange( + 'system_prompt_override', + value, + ) + } + extraText={t( + '如果用户请求中包含系统提示词,则使用此设置拼接到用户的系统提示词前面', + )} + /> ); return ( - <> - -
- {!isEdit && clipboardConfig && ( - - {t('检测到剪贴板中的连接信息')} -
- - -
-
- } - /> - )} - {/* Core Configuration Card - Always Visible */} - - {/* Header */} -
- - - -
- - {t('核心配置')} - -
- {t('创建渠道所需的基本信息')} -
-
-
- - {isIonetChannel && ( + <> + +
+ {!isEdit && clipboardConfig && ( - - {ionetMetadata?.deployment_id && ( - - )} - - + className='ec-dbcd0a3c01b55203' + description={ +
+ {t('检测到剪贴板中的连接信息')} +
+ + +
+
+ } + /> )} + {/* Core Configuration Card - Always Visible */} + + {/* Header */} +
+ + + +
+ + {t('核心配置')} + +
+ {t('创建渠道所需的基本信息')} +
+
+
- setChannelSearchValue(value)} - renderOptionItem={renderChannelOption} - onChange={(value) => handleInputChange('type', value)} - disabled={isIonetLocked} - /> + {isIonetChannel && ( + + + {ionetMetadata?.deployment_id && ( + + )} + + + )} - {inputs.type === 57 && ( - setChannelSearchValue(value)} + renderOptionItem={renderChannelOption} + onChange={(value) => handleInputChange('type', value)} + disabled={isIonetLocked} /> - )} - {inputs.type === 20 && ( - { - setIsEnterpriseAccount(value); - handleInputChange('is_enterprise_account', value); - }} - extraText={t( - '企业账户为特殊返回格式,需要特殊处理,如果非企业账户,请勿勾选', - )} - initValue={inputs.is_enterprise_account} + {inputs.type === 57 && ( + + )} + + {inputs.type === 20 && ( + { + setIsEnterpriseAccount(value); + handleInputChange('is_enterprise_account', value); + }} + extraText={t( + '企业账户为特殊返回格式,需要特殊处理,如果非企业账户,请勿勾选', + )} + initValue={inputs.is_enterprise_account} + /> + )} + + handleInputChange('name', value)} + autoComplete='new-password' /> - )} - handleInputChange('name', value)} - autoComplete='new-password' - /> + {inputs.type === 33 && ( + <> + { + handleChannelOtherSettingsChange( + 'aws_key_type', + value, + ); + }} + extraText={t( + 'AK/SK 模式:使用 AccessKey 和 SecretAccessKey;API Key 模式:使用 API Key', + )} + /> + + )} - {inputs.type === 33 && ( - <> + {inputs.type === 41 && ( { + // 更新设置中的 vertex_key_type handleChannelOtherSettingsChange( - 'aws_key_type', + 'vertex_key_type', value, ); - }} - extraText={t( - 'AK/SK 模式:使用 AccessKey 和 SecretAccessKey;API Key 模式:使用 API Key', - )} - /> - - )} - - {inputs.type === 41 && ( - { - // 更新设置中的 vertex_key_type - handleChannelOtherSettingsChange( - 'vertex_key_type', - value, - ); - // 切换为 api_key 时,关闭批量与手动/文件切换,并清理已选文件 - if (value === 'api_key') { - setBatch(false); - setUseManualInput(false); - setVertexKeys([]); - setVertexFileList([]); - if (formApiRef.current) { - formApiRef.current.setValue('vertex_files', []); + // 切换为 api_key 时,关闭批量与手动/文件切换,并清理已选文件 + if (value === 'api_key') { + setBatch(false); + setUseManualInput(false); + setVertexKeys([]); + setVertexFileList([]); + if (formApiRef.current) { + formApiRef.current.setValue('vertex_files', []); + } } - } - }} - extraText={ - inputs.vertex_key_type === 'api_key' - ? t('API Key 模式下不支持批量创建') - : t('JSON 模式支持手动输入或上传服务账号 JSON') - } - /> - )} - {batch ? ( - inputs.type === 41 && - (inputs.vertex_key_type || 'json') === 'json' ? ( - } - dragMainText={t('点击上传文件或拖拽文件到这里')} - dragSubText={t('仅支持 JSON 文件,支持多文件')} - style={{ marginTop: 10 }} - uploadTrigger='custom' - beforeUpload={() => false} - onChange={handleVertexUploadChange} - fileList={vertexFileList} - rules={ - isEdit - ? [] - : [ - { - required: true, - message: t('请上传密钥文件'), - }, - ] - } - extraText={batchExtra} - /> - ) : ( - handleInputChange('key', value)} - disabled={isIonetLocked} + }} extraText={ -
- {isEdit && - isMultiKeyChannel && - keyMode === 'append' && ( - - {t( - '追加模式:新密钥将添加到现有密钥列表的末尾', - )} - - )} - {isEdit && ( - - )} - {batchExtra} -
+ inputs.vertex_key_type === 'api_key' + ? t('API Key 模式下不支持批量创建') + : t('JSON 模式支持手动输入或上传服务账号 JSON') } - showClear /> - ) - ) : ( - <> - {inputs.type === 57 ? ( - <> - - handleInputChange('key', value) - } - disabled={isIonetLocked} - extraText={ -
- - {t( - '仅支持 JSON 对象,必须包含 access_token 与 account_id', - )} - - - - - {isEdit && ( - - )} - - {isEdit && ( - - )} - {batchExtra} - -
- } - autosize - showClear - /> - - setCodexOAuthModalVisible(false)} - onSuccess={handleCodexOAuthGenerated} - /> - - ) : inputs.type === 41 && - (inputs.vertex_key_type || 'json') === 'json' ? ( - <> - {!batch && ( -
- - {t('密钥输入方式')} - - - + )} + {batch ? ( + inputs.type === 41 && + (inputs.vertex_key_type || 'json') === 'json' ? ( + } + dragMainText={t('点击上传文件或拖拽文件到这里')} + dragSubText={t('仅支持 JSON 文件,支持多文件')} + style={{ marginTop: 10 }} + uploadTrigger='custom' + beforeUpload={() => false} + onChange={handleVertexUploadChange} + fileList={vertexFileList} + rules={ + isEdit + ? [] + : [ + { + required: true, + message: t('请上传密钥文件'), + }, + ] + } + extraText={batchExtra} + /> + ) : ( + + handleInputChange('key', value) + } + disabled={isIonetLocked} + extraText={ +
+ {isEdit && + isMultiKeyChannel && + keyMode === 'append' && ( + + {t( + '追加模式:新密钥将添加到现有密钥列表的末尾', + )} + + )} + {isEdit && ( - -
- )} - - {batch && ( - - )} - - {useManualInput && !batch ? ( + {batchExtra} +
+ } + showClear + /> + ) + ) : ( + <> + {inputs.type === 57 ? ( + <> { : t('密钥') } placeholder={t( - '请输入 JSON 格式的密钥内容,例如:\n{\n "type": "service_account",\n "project_id": "your-project-id",\n "private_key_id": "...",\n "private_key": "...",\n "client_email": "...",\n "client_id": "...",\n "auth_uri": "...",\n "token_uri": "...",\n "auth_provider_x509_cert_url": "...",\n "client_x509_cert_url": "..."\n}', + '请输入 JSON 格式的 OAuth 凭据,例如:\n{\n "access_token": "...",\n "account_id": "..." \n}', )} rules={ isEdit @@ -3019,788 +3108,1048 @@ const EditChannelModal = (props) => { onChange={(value) => handleInputChange('key', value) } + disabled={isIonetLocked} extraText={ -
+
- {t('请输入完整的 JSON 格式密钥内容')} + {t( + '仅支持 JSON 对象,必须包含 access_token 与 account_id', + )} - {isEdit && - isMultiKeyChannel && - keyMode === 'append' && ( - - {t( - '追加模式:新密钥将添加到现有密钥列表的末尾', - )} - + + + + {isEdit && ( + )} - {isEdit && ( - )} - {batchExtra} + {isEdit && ( + + )} + {batchExtra} +
} autosize showClear /> - ) : ( - } - dragMainText={t('点击上传文件或拖拽文件到这里')} - dragSubText={t('仅支持 JSON 文件')} - style={{ marginTop: 10 }} - uploadTrigger='custom' - beforeUpload={() => false} - onChange={handleVertexUploadChange} - fileList={vertexFileList} - rules={ - isEdit - ? [] - : [ - { - required: true, - message: t('请上传密钥文件'), - }, - ] + + + setCodexOAuthModalVisible(false) } - extraText={batchExtra} + onSuccess={handleCodexOAuthGenerated} /> - )} - - ) : ( - - handleInputChange('key', value) - } - extraText={ -
- {isEdit && - isMultiKeyChannel && - keyMode === 'append' && ( - - {t( - '追加模式:新密钥将添加到现有密钥列表的末尾', - )} - - )} - {isEdit && ( - - )} - {batchExtra} -
- } - showClear - /> - )} - - )} - - {isEdit && isMultiKeyChannel && ( - setKeyMode(value)} - extraText={ - - {keyMode === 'replace' - ? t('覆盖模式:将完全替换现有的所有密钥') - : t('追加模式:将新密钥添加到现有密钥列表末尾')} - - } - /> - )} - {batch && multiToSingle && ( - <> - { - setMultiKeyMode(value); - handleInputChange('multi_key_mode', value); - }} - /> - {inputs.multi_key_mode === 'polling' && ( - - )} - - )} - - {inputs.type === 18 && ( - handleInputChange('other', value)} - showClear - /> - )} - - {inputs.type === 41 && ( - handleInputChange('other', value)} - rules={[ - { required: true, message: t('请填写部署地区') }, - ]} - template={REGION_EXAMPLE} - templateLabel={t('填入模板')} - editorType='region' - formApi={formApiRef.current} - extraText={t('设置默认地区和特定模型的专用地区')} - /> - )} - - {inputs.type === 21 && ( - handleInputChange('other', value)} - showClear - /> - )} - - {inputs.type === 39 && ( - handleInputChange('other', value)} - showClear - /> - )} - - {inputs.type === 49 && ( - handleInputChange('other', value)} - showClear - /> - )} - - {inputs.type === 1 && ( - - handleInputChange('openai_organization', value) - } - /> - )} - - {/* API Configuration Section */} - {showApiConfigCard && ( -
+ + ) : inputs.type === 41 && + (inputs.vertex_key_type || 'json') === 'json' ? ( + <> + {!batch && ( +
+ + {t('密钥输入方式')} + + + + + +
+ )} - {inputs.type === 40 && ( - - {t('邀请链接')}: - - window.open( - 'https://cloud.siliconflow.cn/i/hij0YNTZ', - ) - } - > - https://cloud.siliconflow.cn/i/hij0YNTZ - -
- } - className='!rounded-lg' - /> - )} + {batch && ( + + )} - {inputs.type === 3 && ( - <> - -
- + handleInputChange('key', value) + } + extraText={ +
+ + {t('请输入完整的 JSON 格式密钥内容')} + + {isEdit && + isMultiKeyChannel && + keyMode === 'append' && ( + + {t( + '追加模式:新密钥将添加到现有密钥列表的末尾', + )} + + )} + {isEdit && ( + + )} + {batchExtra} +
+ } + autosize + showClear + /> + ) : ( + } + dragMainText={t( + '点击上传文件或拖拽文件到这里', + )} + dragSubText={t('仅支持 JSON 文件')} + style={{ marginTop: 10 }} + uploadTrigger='custom' + beforeUpload={() => false} + onChange={handleVertexUploadChange} + fileList={vertexFileList} + rules={ + isEdit + ? [] + : [ + { + required: true, + message: t('请上传密钥文件'), + }, + ] + } + extraText={batchExtra} + /> )} - onChange={(value) => - handleInputChange('base_url', value) - } - showClear - disabled={isIonetLocked} - /> -
-
+ + ) : ( - handleInputChange('other', value) + field='key' + label={ + isEdit + ? t('密钥(编辑模式下,保存的密钥不会显示)') + : t('密钥') } - showClear - /> -
-
- - handleChannelOtherSettingsChange( - 'azure_responses_version', - value, - ) + handleInputChange('key', value) + } + extraText={ +
+ {isEdit && + isMultiKeyChannel && + keyMode === 'append' && ( + + {t( + '追加模式:新密钥将添加到现有密钥列表的末尾', + )} + + )} + {isEdit && ( + + )} + {batchExtra} +
} showClear /> -
+ )} )} - {inputs.type === 8 && ( + {isEdit && isMultiKeyChannel && ( + setKeyMode(value)} + extraText={ + + {keyMode === 'replace' + ? t('覆盖模式:将完全替换现有的所有密钥') + : t('追加模式:将新密钥添加到现有密钥列表末尾')} + + } + /> + )} + {batch && multiToSingle && ( <> - { + setMultiKeyMode(value); + handleInputChange('multi_key_mode', value); + }} /> -
- - handleInputChange('base_url', value) - } - showClear - disabled={isIonetLocked} + className='!rounded-lg mt-2' /> -
+ )} )} - {inputs.type === 37 && ( - + handleInputChange('other', value) + } + showClear + /> + )} + + {inputs.type === 41 && ( + + handleInputChange('other', value) + } + rules={[ + { required: true, message: t('请填写部署地区') }, + ]} + template={REGION_EXAMPLE} + templateLabel={t('填入模板')} + editorType='region' + formApi={formApiRef.current} + extraText={t('设置默认地区和特定模型的专用地区')} /> )} - {inputs.type !== 3 && - inputs.type !== 8 && - inputs.type !== 22 && - inputs.type !== 36 && - (inputs.type !== 45 || doubaoApiEditUnlocked) && ( -
- - handleInputChange('base_url', value) + {inputs.type === 21 && ( + + handleInputChange('other', value) + } + showClear + /> + )} + + {inputs.type === 39 && ( + + handleInputChange('other', value) + } + showClear + /> + )} + + {inputs.type === 49 && ( + + handleInputChange('other', value) + } + showClear + /> + )} + + {inputs.type === 1 && ( + + handleInputChange('openai_organization', value) + } + /> + )} + + {/* API Configuration Section */} + {showApiConfigCard && ( +
+ {inputs.type === 40 && ( + + {t('邀请链接')}: + + window.open( + 'https://cloud.siliconflow.cn/i/hij0YNTZ', + ) + } + > + https://cloud.siliconflow.cn/i/hij0YNTZ + +
} - showClear - disabled={isIonetLocked} - extraText={t( - '对于官方渠道,new-api已经内置地址,除非是第三方代理站点或者Azure的特殊接入地址,否则不需要填写', + className='!rounded-lg' + /> + )} + + {inputs.type === 3 && ( + <> + +
+ + handleInputChange('base_url', value) + } + showClear + disabled={isIonetLocked} + /> +
+
+ + handleInputChange('other', value) + } + showClear + /> +
+
+ + handleChannelOtherSettingsChange( + 'azure_responses_version', + value, + ) + } + showClear + /> +
+ + )} + + {inputs.type === 8 && ( + <> + +
+ + handleInputChange('base_url', value) + } + showClear + disabled={isIonetLocked} + /> +
+ + )} + + {inputs.type === 37 && ( + -
- )} + )} - {inputs.type === 22 && ( -
- + + handleInputChange('base_url', value) + } + showClear + disabled={isIonetLocked} + extraText={t( + '对于官方渠道,new-api已经内置地址,除非是第三方代理站点或者Azure的特殊接入地址,否则不需要填写', + )} + /> +
)} - onChange={(value) => - handleInputChange('base_url', value) - } - showClear - disabled={isIonetLocked} - /> -
- )} - {inputs.type === 36 && ( -
- - handleInputChange('base_url', value) - } - showClear - disabled={isIonetLocked} - /> -
- )} + {inputs.type === 22 && ( +
+ + handleInputChange('base_url', value) + } + showClear + disabled={isIonetLocked} + /> +
+ )} - {inputs.type === 45 && !doubaoApiEditUnlocked && ( -
- - handleInputChange('base_url', value) - } - optionList={[ - { - value: 'https://ark.cn-beijing.volces.com', - label: 'https://ark.cn-beijing.volces.com', - }, - { - value: - 'https://ark.ap-southeast.bytepluses.com', - label: - 'https://ark.ap-southeast.bytepluses.com', - }, - { - value: DEPRECATED_DOUBAO_CODING_PLAN_BASE_URL, - label: doubaoCodingPlanOptionLabel, - disabled: !canKeepDeprecatedDoubaoCodingPlan, - }, - ]} - defaultValue='https://ark.cn-beijing.volces.com' - disabled={isIonetLocked} - /> + {inputs.type === 36 && ( +
+ + handleInputChange('base_url', value) + } + showClear + disabled={isIonetLocked} + /> +
+ )} + + {inputs.type === 45 && !doubaoApiEditUnlocked && ( +
+ + handleInputChange('base_url', value) + } + optionList={[ + { + value: 'https://ark.cn-beijing.volces.com', + label: 'https://ark.cn-beijing.volces.com', + }, + { + value: + 'https://ark.ap-southeast.bytepluses.com', + label: + 'https://ark.ap-southeast.bytepluses.com', + }, + { + value: + DEPRECATED_DOUBAO_CODING_PLAN_BASE_URL, + label: doubaoCodingPlanOptionLabel, + disabled: + !canKeepDeprecatedDoubaoCodingPlan, + }, + ]} + defaultValue='https://ark.cn-beijing.volces.com' + disabled={isIonetLocked} + /> +
+ )}
)} -
- )} - {/* Model Selection - Part of Core Config */} - setModelSearchValue(value)} - innerBottomSlot={ - modelSearchHintText ? ( - - {modelSearchHintText} - - ) : null - } - style={{ width: '100%' }} - onChange={(value) => handleInputChange('models', value)} - renderSelectedItem={(optionNode) => { - const modelName = String(optionNode?.value ?? ''); - return { - isRenderInTag: true, - content: ( - { - e.stopPropagation(); - const ok = await copy(modelName); - if (ok) { - showSuccess( - t('已复制:{{name}}', { name: modelName }), - ); - } else { - showError(t('复制失败')); - } - }} - > - {optionNode.label || modelName} - - ), - }; - }} - extraText={ - - - {MODEL_FETCHABLE_CHANNEL_TYPES.has(inputs.type) && ( + {/* Model Selection - Part of Core Config */} + setModelSearchValue(value)} + innerBottomSlot={ + modelSearchHintText ? ( + + {modelSearchHintText} + + ) : null + } + style={{ width: '100%' }} + onChange={(value) => handleInputChange('models', value)} + renderSelectedItem={(optionNode) => { + const modelName = String(optionNode?.value ?? ''); + return { + isRenderInTag: true, + content: ( + { + e.stopPropagation(); + const ok = await copy(modelName); + if (ok) { + showSuccess( + t('已复制:{{name}}', { + name: modelName, + }), + ); + } else { + showError(t('复制失败')); + } + }} + > + {optionNode.label || modelName} + + ), + }; + }} + extraText={ + - )} - handleInputChange('models', fullModels) }, - ...(inputs.type === 4 && isEdit ? [{ node: 'item', name: t('Ollama 模型管理'), onClick: () => setOllamaModalVisible(true) }] : []), - { node: 'divider' }, - { node: 'item', name: t('复制所有模型'), onClick: () => { - if (inputs.models.length === 0) { showInfo(t('没有模型可以复制')); return; } - try { copy(inputs.models.join(',')); showSuccess(t('模型列表已复制到剪贴板')); } catch (error) { showError(t('复制失败')); } - }}, - { node: 'item', name: t('清除所有模型'), type: 'danger', onClick: () => handleInputChange('models', []) }, - ...((modelGroups && modelGroups.length > 0) ? [ + {MODEL_FETCHABLE_CHANNEL_TYPES.has(inputs.type) && ( + + )} + + handleInputChange('models', fullModels), + }, + ...(inputs.type === 4 && isEdit + ? [ + { + node: 'item', + name: t('Ollama 模型管理'), + onClick: () => + setOllamaModalVisible(true), + }, + ] + : []), { node: 'divider' }, - ...modelGroups.map((group) => ({ + { node: 'item', - name: group.name, + name: t('复制所有模型'), onClick: () => { - let items = []; + if (inputs.models.length === 0) { + showInfo(t('没有模型可以复制')); + return; + } try { - if (Array.isArray(group.items)) { items = group.items; } - else if (typeof group.items === 'string') { - const parsed = JSON.parse(group.items || '[]'); - if (Array.isArray(parsed)) items = parsed; - } - } catch {} - const current = formApiRef.current?.getValue('models') || inputs.models || []; - const merged = Array.from(new Set([...current, ...items].map((m) => (m || '').trim()).filter(Boolean))); - handleInputChange('models', merged); + copy(inputs.models.join(',')); + showSuccess(t('模型列表已复制到剪贴板')); + } catch (error) { + showError(t('复制失败')); + } }, - })), - ] : []), - ]} + }, + { + node: 'item', + name: t('清除所有模型'), + type: 'danger', + onClick: () => + handleInputChange('models', []), + }, + ...(modelGroups && modelGroups.length > 0 + ? [ + { node: 'divider' }, + ...modelGroups.map((group) => ({ + node: 'item', + name: group.name, + onClick: () => { + let items = []; + try { + if (Array.isArray(group.items)) { + items = group.items; + } else if ( + typeof group.items === 'string' + ) { + const parsed = JSON.parse( + group.items || '[]', + ); + if (Array.isArray(parsed)) + items = parsed; + } + } catch {} + const current = + formApiRef.current?.getValue( + 'models', + ) || + inputs.models || + []; + const merged = Array.from( + new Set( + [...current, ...items] + .map((m) => (m || '').trim()) + .filter(Boolean), + ), + ); + handleInputChange('models', merged); + }, + })), + ] + : []), + ]} + > + + + + } + /> + + {/* Custom Model Name - Core Config */} + setCustomModel(value.trim())} + value={customModel} + suffix={ + - - - } - /> + {t('填入')} + + } + /> - {/* Custom Model Name - Core Config */} - setCustomModel(value.trim())} - value={customModel} - suffix={ - - } - /> + {/* Groups - Core Config */} + handleInputChange('groups', value)} + /> - {/* Groups - Core Config */} - handleInputChange('groups', value)} - /> + {/* Model Mapping - Core Config */} + + handleInputChange('model_mapping', value) + } + template={MODEL_MAPPING_EXAMPLE} + templateLabel={t('填入模板')} + editorType='keyValue' + formApi={formApiRef.current} + renderStringValueSuffix={({ pairKey, value }) => { + if (!MODEL_FETCHABLE_CHANNEL_TYPES.has(inputs.type)) { + return null; + } + const disabled = !String(pairKey ?? '').trim(); + return ( + +