Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
4cb483d
Keep Flatkey authoritative while Google serves generated video bytes
think-back Aug 10, 2026
b2472d5
Give Seedance 2.5 traffic an isolated upstream protocol boundary
think-back Aug 10, 2026
596c5dc
Reuse durable video delivery without creating unbounded telemetry
think-back Aug 10, 2026
523102f
Do not report generated video success before a durable copy exists
think-back Aug 10, 2026
5b71db6
Expose the new video supplier through existing Flatkey administration…
think-back Aug 10, 2026
8e5e4bf
Keep Flatkey as the only public video address while Google serves the…
think-back Aug 10, 2026
b9488fc
Translate the shared Seedance contract without leaking supplier seman…
think-back Aug 10, 2026
b4af942
Protect ModelAPI Seedance wire contract and white-label failures
think-back Aug 10, 2026
bd51d6a
Keep ModelAPISeedance classic channel presentation provider-neutral
think-back Aug 10, 2026
9057759
Align ModelAPI Seedance input groups with official optional shape
think-back Aug 10, 2026
df909fd
Prevent branded video polling leaks
think-back Aug 10, 2026
93cbcff
Prevent unsafe video archive fetch paths
think-back Aug 10, 2026
71d2a3a
Keep classic ModelAPISeedance regression test lintable
think-back Aug 10, 2026
adfc24a
Constrain video polling diagnostic exposure
think-back Aug 10, 2026
d5437d5
Keep video archive fetches on guarded direct clients
think-back Aug 10, 2026
ab683a1
Keep archived video polling diagnostics provider-neutral
think-back Aug 10, 2026
1e30cd4
Make archived polling brand regression case-insensitive
think-back Aug 10, 2026
ee76660
Prevent upstream task identifiers from entering persisted public task…
think-back Aug 10, 2026
fe5f937
Close runtime safety gaps for ModelAPI Seedance upstream
think-back Aug 10, 2026
aa60606
Keep whitespace proxy settings off ModelAPI Seedance runtime paths
think-back Aug 10, 2026
1ffe882
Restrict ModelAPI Seedance to Flatkey video routes
think-back Aug 10, 2026
39b6605
Document the approved Seedance 2.5 billing contract
think-back Aug 11, 2026
94b3cfd
Turn the approved Seedance billing design into an executable delivery…
think-back Aug 11, 2026
5ba47a5
Make Seedance 2.5 reservations follow the upstream task snapshot
think-back Aug 11, 2026
1203e04
Give Seedance 2.5 a stable fixed-price billing base
think-back Aug 11, 2026
62ce112
Keep the Seedance integration current with production main
think-back Aug 11, 2026
47eb68f
Prove submit-time ModelAPI Seedance billing reaches settlement
think-back Aug 11, 2026
b98aa39
Preserve submit-selected keys for ModelAPI polling
think-back Aug 11, 2026
e720838
Preserve channel-specific video archive proxy boundaries
think-back Aug 11, 2026
ffa8fac
Normalize channel UI formatting for stable frontend checks
think-back Aug 11, 2026
329b611
Define the boundary for URL-native Seedance assets
think-back Aug 11, 2026
68880fd
Make URL-native Seedance delivery executable
think-back Aug 11, 2026
4eb5d93
Keep asynchronous Seedance delivery safe and retryable
think-back Aug 11, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions common/endpoint_type.go
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
7 changes: 7 additions & 0 deletions common/endpoint_type_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
6 changes: 4 additions & 2 deletions constant/channel.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down Expand Up @@ -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{
Expand Down Expand Up @@ -220,6 +221,7 @@ var ChannelTypeNames = map[int]string{
ChannelTypeXaiGrokVideo: "XaiGrokVideo",
ChannelTypeSonilo: "Sonilo",
ChannelTypeMiniMaxH3: "MiniMaxH3",
ChannelTypeModelAPISeedance: "ModelAPISeedance",
}

func GetChannelTypeName(channelType int) string {
Expand Down
21 changes: 21 additions & 0 deletions constant/modelapi_seedance_channel_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
9 changes: 7 additions & 2 deletions controller/asset_task_worker.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
261 changes: 261 additions & 0 deletions controller/asset_task_worker_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"io"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"sync/atomic"
"testing"
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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)
Expand Down
3 changes: 3 additions & 0 deletions controller/channel.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading