From aba34524f1d52c4f1b9fb59886fa6f55d70ef3ee Mon Sep 17 00:00:00 2001 From: Cai Date: Thu, 17 Sep 2026 04:14:14 +0900 Subject: [PATCH] feat: support native Seedance Ark video task API --- backend/internal/handler/endpoint.go | 3 + backend/internal/handler/grok_media.go | 45 +++-- .../internal/handler/grok_media_slots_test.go | 27 ++- backend/internal/handler/seedance.go | 63 +++++++ backend/internal/handler/seedance_test.go | 97 +++++++++++ backend/internal/server/routes/gateway.go | 5 + .../routes/gateway_model_allowlist_test.go | 4 + .../internal/server/routes/seedance_test.go | 34 ++++ backend/internal/service/account.go | 5 + backend/internal/service/grok_media.go | 13 +- backend/internal/service/seedance.go | 154 ++++++++++++++++++ backend/internal/service/seedance_test.go | 112 +++++++++++++ docs/seedance-api.md | 45 +++++ .../account/BulkEditAccountModal.vue | 9 +- .../components/account/CreateAccountModal.vue | 9 +- .../components/account/EditAccountModal.vue | 11 +- .../__tests__/BulkEditAccountModal.spec.ts | 12 ++ .../__tests__/EditAccountModal.spec.ts | 13 ++ frontend/src/types/index.ts | 2 +- 19 files changed, 631 insertions(+), 32 deletions(-) create mode 100644 backend/internal/handler/seedance.go create mode 100644 backend/internal/handler/seedance_test.go create mode 100644 backend/internal/server/routes/seedance_test.go create mode 100644 backend/internal/service/seedance.go create mode 100644 backend/internal/service/seedance_test.go create mode 100644 docs/seedance-api.md diff --git a/backend/internal/handler/endpoint.go b/backend/internal/handler/endpoint.go index c1bde61d63..2115c2d7c0 100644 --- a/backend/internal/handler/endpoint.go +++ b/backend/internal/handler/endpoint.go @@ -29,6 +29,7 @@ const ( EndpointVideosEdits = "/v1/videos/edits" EndpointVideosExtensions = "/v1/videos/extensions" EndpointVideos = "/v1/videos" + EndpointSeedanceTasks = "/api/v3/contents/generations/tasks" EndpointGeminiModels = "/v1beta/models" ) @@ -81,6 +82,8 @@ const ( func NormalizeInboundEndpoint(path string) string { path = strings.TrimSpace(path) switch { + case strings.Contains(path, "/contents/generations/tasks"): + return EndpointSeedanceTasks case strings.Contains(path, EndpointResponsesInputTokens) || isResponsesInputTokensAliasPath(path): return EndpointResponsesInputTokens case strings.Contains(path, EndpointEmbeddings): diff --git a/backend/internal/handler/grok_media.go b/backend/internal/handler/grok_media.go index e5ab1dd04c..b61cd9515b 100644 --- a/backend/internal/handler/grok_media.go +++ b/backend/internal/handler/grok_media.go @@ -52,6 +52,12 @@ func (h *OpenAIGatewayHandler) GrokVideoContent(c *gin.Context) { } func (h *OpenAIGatewayHandler) handleGrokMedia(c *gin.Context, endpoint service.GrokMediaEndpoint, requestID string) { + platform := service.PlatformGrok + noAccountCode, noAccountMessage := "grok_media_no_eligible_account", "No eligible Grok media accounts" + if endpoint.IsSeedance() { + platform = service.PlatformOpenAI + noAccountCode, noAccountMessage = "seedance_no_eligible_account", "No eligible Seedance accounts" + } streamStarted := false defer h.recoverResponsesPanic(c, &streamStarted) @@ -99,6 +105,13 @@ func (h *OpenAIGatewayHandler) handleGrokMedia(c *gin.Context, endpoint service. contentType := c.GetHeader("Content-Type") requestInfo := service.ParseGrokMediaRequest(contentType, body) + if endpoint == service.SeedanceEndpointCreate { + requestInfo, err = service.ParseSeedanceRequest(body) + if err != nil { + h.errorResponse(c, http.StatusBadRequest, "invalid_request_error", err.Error()) + return + } + } requestModel := requestInfo.Model routingModel := service.NormalizeGrokMediaModelForEndpoint(endpoint, requestModel, requestInfo.HasInputImage()) if endpoint.IsGenerationRequest() && strings.TrimSpace(requestModel) == "" { @@ -215,8 +228,8 @@ func (h *OpenAIGatewayHandler) handleGrokMedia(c *gin.Context, endpoint service. var selection *service.AccountSelectionResult var scheduleDecision service.OpenAIAccountScheduleDecision if boundLookupAccountID > 0 { - selection, scheduleDecision, err = h.gatewayService.SelectGrokMediaVideoRequestAccount( - requestCtx, apiKey.GroupID, sessionHash, boundLookupAccountID, routingModel, + selection, scheduleDecision, err = h.gatewayService.SelectMediaVideoRequestAccount( + requestCtx, apiKey.GroupID, sessionHash, boundLookupAccountID, routingModel, platform, ) } else { selection, scheduleDecision, err = h.gatewayService.SelectAccountWithSchedulerForCapability( @@ -231,7 +244,7 @@ func (h *OpenAIGatewayHandler) handleGrokMedia(c *gin.Context, endpoint service. false, false, false, - service.PlatformGrok, + platform, ) } // Own an eagerly acquired slot before any rejection or eligibility probe. @@ -256,11 +269,11 @@ func (h *OpenAIGatewayHandler) handleGrokMedia(c *gin.Context, endpoint service. if endpoint.IsGenerationRequest() && errors.Is(err, service.ErrNoAvailableAccounts) && (len(failedAccountIDs) == 0 || (mediaEligibilityRejected && lastFailoverErr == nil)) { markOpsRoutingCapacityLimitedIfNoAvailable(c, err) - h.errorResponse(c, http.StatusServiceUnavailable, "grok_media_no_eligible_account", "No eligible Grok media accounts") + h.errorResponse(c, http.StatusServiceUnavailable, noAccountCode, noAccountMessage) return } if len(failedAccountIDs) == 0 { - cls := classifyNoAccountErrorFromGin(c, h.gatewayService, apiKey, requestModel, routingModel, service.PlatformGrok) + cls := classifyNoAccountErrorFromGin(c, h.gatewayService, apiKey, requestModel, routingModel, platform) if !cls.ModelNotFound { markOpsRoutingCapacityLimitedIfNoAvailable(c, err) } @@ -277,10 +290,10 @@ func (h *OpenAIGatewayHandler) handleGrokMedia(c *gin.Context, endpoint service. if selection == nil || selection.Account == nil { if endpoint.IsGenerationRequest() { markOpsRoutingCapacityLimited(c) - h.errorResponse(c, http.StatusServiceUnavailable, "grok_media_no_eligible_account", "No eligible Grok media accounts") + h.errorResponse(c, http.StatusServiceUnavailable, noAccountCode, noAccountMessage) return } - cls := classifyNoAccountErrorFromGin(c, h.gatewayService, apiKey, requestModel, routingModel, service.PlatformGrok) + cls := classifyNoAccountErrorFromGin(c, h.gatewayService, apiKey, requestModel, routingModel, platform) if !cls.ModelNotFound { markOpsRoutingCapacityLimited(c) } @@ -309,7 +322,7 @@ func (h *OpenAIGatewayHandler) handleGrokMedia(c *gin.Context, endpoint service. ) account := selection.Account - if endpoint.IsGenerationRequest() { + if endpoint.IsGenerationRequest() && !endpoint.IsSeedance() { eligible, eligibilityReason, eligibilityErr := h.ensureGrokMediaAccountEligibility(requestCtx, account) if !eligible { releaseAccount() @@ -360,6 +373,9 @@ func (h *OpenAIGatewayHandler) handleGrokMedia(c *gin.Context, endpoint service. writerSizeBeforeForward := c.Writer.Size() result, err := func() (*service.OpenAIForwardResult, error) { defer releaseAccount() + if endpoint.IsSeedance() { + return h.gatewayService.ForwardSeedance(requestCtx, c, account, endpoint, requestID, body) + } return h.gatewayService.ForwardGrokMedia(requestCtx, c, account, endpoint, requestID, body, contentType) }() @@ -490,7 +506,11 @@ func (h *OpenAIGatewayHandler) handleGrokMedia(c *gin.Context, endpoint service. } // Status poll OR content download can observe official done+video.url. // Both paths share the same claim key so the customer is charged once. - if endpoint == service.GrokMediaEndpointVideoStatus || endpoint == service.GrokMediaEndpointVideoContent { + if endpoint == service.SeedanceEndpointStatus { + if billResult := prepareSeedanceCompletionBilling(requestCtx, h, apiKey, subject, requestID, result); billResult != nil { + recordGrokMediaUsage(c, h, reqLog, apiKey, subject, subscription, account, billResult, billResult.Model, body, requestID) + } + } else if endpoint == service.GrokMediaEndpointVideoStatus || endpoint == service.GrokMediaEndpointVideoContent { taskID := strings.TrimSpace(requestID) if billResult := prepareGrokVideoCompletionBilling(requestCtx, h, reqLog, apiKey, subject, taskID, result); billResult != nil { recordGrokMediaUsage(c, h, reqLog, apiKey, subject, subscription, account, billResult, billResult.Model, body, taskID) @@ -521,6 +541,9 @@ func (h *OpenAIGatewayHandler) ensureGrokMediaAccountEligibility(ctx context.Con } func grokMediaRequiredCapability(endpoint service.GrokMediaEndpoint) service.OpenAIEndpointCapability { + if endpoint.IsSeedance() { + return service.OpenAIEndpointCapabilitySeedance + } if endpoint.IsGenerationRequest() { return service.OpenAIEndpointCapabilityGrokMediaGeneration } @@ -539,7 +562,7 @@ func grokMediaScheduleModel(account *service.Account, routingModel string, resul func isGrokVideoCreateEndpoint(endpoint service.GrokMediaEndpoint) bool { switch endpoint { - case service.GrokMediaEndpointVideosGenerations, + case service.SeedanceEndpointCreate, service.GrokMediaEndpointVideosGenerations, service.GrokMediaEndpointVideosEdits, service.GrokMediaEndpointVideosExtensions: return true @@ -712,7 +735,7 @@ func recordGrokMediaUsage( } // Async video: force durable task request id and release claim if billing fails. videoTaskID := "" - if result != nil && result.VideoCount > 0 { + if result != nil && (result.VideoCount > 0 || strings.HasPrefix(result.ResponseID, "seedance:")) { videoTaskID = strings.TrimSpace(firstNonEmptyString(requestID, result.ResponseID)) if stable := service.StableGrokVideoBillingRequestID(firstNonEmptyString(result.ResponseID, requestID)); stable != "" { result.RequestID = stable diff --git a/backend/internal/handler/grok_media_slots_test.go b/backend/internal/handler/grok_media_slots_test.go index 3393e18344..81f14071da 100644 --- a/backend/internal/handler/grok_media_slots_test.go +++ b/backend/internal/handler/grok_media_slots_test.go @@ -120,10 +120,22 @@ func (s *grokMediaSlotsCache) assertReleased(t *testing.T) { type grokMediaSlotBindings struct { testutil.StubGatewayCache - owner int64 - writes int - key string - billed map[string]bool + owner int64 + writes int + key string + billed map[string]bool + pending map[string][]byte +} + +func (s *grokMediaSlotBindings) SetGrokVideoPendingBilling(_ context.Context, key string, body []byte, _ time.Duration) error { + if s.pending == nil { + s.pending = make(map[string][]byte) + } + s.pending[key] = append([]byte(nil), body...) + return nil +} +func (s *grokMediaSlotBindings) GetGrokVideoPendingBilling(_ context.Context, key string) ([]byte, error) { + return s.pending[key], nil } func (s *grokMediaSlotBindings) GetSessionAccountID(_ context.Context, groupID int64, key string) (int64, error) { @@ -186,7 +198,7 @@ func (p grokMediaSlotProber) ProbeMediaEligibility(ctx context.Context, id int64 return p(ctx, id) } -func newGrokMediaSlotHandler(t *testing.T, oauth, mismatch bool) (*OpenAIGatewayHandler, *grokMediaSlotsCache, *grokMediaSlotBindings, *grokMediaSlotUpstream) { +func newGrokMediaSlotHandler(t *testing.T, oauth, mismatch bool, platforms ...string) (*OpenAIGatewayHandler, *grokMediaSlotsCache, *grokMediaSlotBindings, *grokMediaSlotUpstream) { t.Helper() gin.SetMode(gin.TestMode) accounts := make([]service.Account, 3) @@ -194,6 +206,11 @@ func newGrokMediaSlotHandler(t *testing.T, oauth, mismatch bool) (*OpenAIGateway accounts[i] = service.Account{ID: int64(i + 1), Platform: service.PlatformGrok, Type: service.AccountTypeAPIKey, Status: service.StatusActive, Schedulable: true, Concurrency: 50, Priority: i, GroupIDs: []int64{24}, Credentials: map[string]any{"api_key": "test-key", "access_token": "test-token"}} + if len(platforms) > 0 { + accounts[i].Platform = platforms[0] + accounts[i].Credentials["base_url"] = "https://ark.cn-beijing.volces.com/api/v3" + accounts[i].Credentials["openai_capabilities"] = []string{"seedance"} + } if oauth { accounts[i].Type = service.AccountTypeOAuth accounts[i].Credentials["refresh_token"] = "test-refresh" diff --git a/backend/internal/handler/seedance.go b/backend/internal/handler/seedance.go new file mode 100644 index 0000000000..ce76a43fc9 --- /dev/null +++ b/backend/internal/handler/seedance.go @@ -0,0 +1,63 @@ +package handler + +import ( + "context" + "mime" + "net/http" + "time" + + middleware "github.com/Wei-Shaw/sub2api/internal/server/middleware" + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/gin-gonic/gin" +) + +// SeedanceTasks exposes Ark's native asynchronous video task protocol. +func (h *OpenAIGatewayHandler) SeedanceTasks(c *gin.Context) { + if c.Request.Method == http.MethodPost && c.GetHeader("Content-Type") != "" { + mediaType, _, err := mime.ParseMediaType(c.GetHeader("Content-Type")) + if err != nil || mediaType != "application/json" { + h.errorResponse(c, http.StatusUnsupportedMediaType, "invalid_request_error", "Seedance requires application/json") + return + } + } + key, ok := middleware.GetAPIKeyFromContext(c) + if !ok || key.Group == nil || (key.Group.Platform != service.PlatformOpenAI && key.Group.Platform != service.PlatformComposite) { + h.errorResponse(c, http.StatusForbidden, "permission_error", "Seedance requires an OpenAI or composite group") + return + } + endpoint := service.SeedanceEndpointCreate + setActualUpstreamEndpoint(c, EndpointSeedanceTasks) + taskID := "" + if c.Request.Method != http.MethodPost { + taskID = service.SeedanceTaskKey(c.Param("task_id")) + endpoint = service.SeedanceEndpointStatus + if c.Request.Method == http.MethodDelete { + endpoint = service.SeedanceEndpointDelete + } + } + h.handleGrokMedia(c, endpoint, taskID) +} + +// Ark reports actual completion tokens. Never infer tokens from duration or use +// Grok's per-second video tariff. Repeated polls share the durable task dedup key. +func prepareSeedanceCompletionBilling(ctx context.Context, h *OpenAIGatewayHandler, key *service.APIKey, subject middleware.AuthSubject, taskID string, result *service.OpenAIForwardResult) *service.OpenAIForwardResult { + if result == nil || result.Usage.OutputTokens <= 0 { + return nil + } + pending, err := h.gatewayService.LoadGrokVideoPendingBilling(ctx, taskID, subject.UserID, key.ID) + if err != nil || pending == nil { + return nil + } + claimed, err := h.gatewayService.ClaimGrokVideoBilling(ctx, taskID, subject.UserID, key.ID) + if err != nil || !claimed { + return nil + } + merged := *result + merged.Model = pending.Model + merged.BillingModel = firstNonEmptyString(pending.BillingModel, pending.Model) + merged.UpstreamModel = firstNonEmptyString(pending.UpstreamModel, result.UpstreamModel) + merged.RequestID = service.StableGrokVideoBillingRequestID(taskID) + merged.ResponseID = taskID + merged.Duration = service.GrokVideoE2EDuration(pending.CreatedAt, time.Now()) + return &merged +} diff --git a/backend/internal/handler/seedance_test.go b/backend/internal/handler/seedance_test.go new file mode 100644 index 0000000000..9ffbecdeda --- /dev/null +++ b/backend/internal/handler/seedance_test.go @@ -0,0 +1,97 @@ +//go:build unit + +package handler + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + middleware "github.com/Wei-Shaw/sub2api/internal/server/middleware" + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +func TestSeedanceHandlerLifecycleAndOwnership(t *testing.T) { + h, slots, bindings, upstream := newGrokMediaSlotHandler(t, false, false, service.PlatformOpenAI) + var owner int64 + upstream.call = func(req *http.Request, id int64) (*http.Response, error) { + body := `{"id":"task-ark","status":"queued"}` + if req.Method == http.MethodPost { + owner = id + } else { + require.Equal(t, owner, id) + } + return &http.Response{StatusCode: 200, Header: http.Header{"Content-Type": []string{"application/json"}}, Body: io.NopCloser(strings.NewReader(body))}, nil + } + newContext := func(method string) (*gin.Context, *httptest.ResponseRecorder) { + c, w := grokMediaSlotContext(context.Background(), method == http.MethodPost) + key, _ := middleware.GetAPIKeyFromContext(c) + key.Group.Platform = service.PlatformOpenAI + body := "" + if method == http.MethodPost { + body = `{"model":"doubao-seedance","content":[{"type":"text","text":"waves"}]}` + } + c.Request = httptest.NewRequest(method, "/api/v3/contents/generations/tasks", strings.NewReader(body)) + c.Params = gin.Params{{Key: "task_id", Value: "task-ark"}} + return c, w + } + c, w := newContext(http.MethodPost) + h.SeedanceTasks(c) + require.Equal(t, 200, w.Code, w.Body.String()) + require.Positive(t, owner) + require.Len(t, bindings.pending, 1) + slots.assertReleased(t) + for _, method := range []string{http.MethodGet, http.MethodDelete} { + c, w = newContext(method) + h.SeedanceTasks(c) + require.Equal(t, 200, w.Code, w.Body.String()) + slots.assertReleased(t) + } + for _, other := range []string{"user", "key", "group", "task", "provider"} { + c, w = newContext(http.MethodGet) + key, _ := middleware.GetAPIKeyFromContext(c) + switch other { + case "user": + c.Set(string(middleware.ContextKeyUser), middleware.AuthSubject{UserID: 11, Concurrency: 5}) + case "key": + key.ID = 21 + case "group": + group := int64(25) + key.GroupID = &group + case "task": + c.Params = gin.Params{{Key: "task_id", Value: "other"}} + case "provider": + c.Params = gin.Params{{Key: "request_id", Value: "task-ark"}} + } + before := upstream.calls + if other == "provider" { + h.GrokVideoStatus(c) + } else { + h.SeedanceTasks(c) + } + require.Equal(t, 404, w.Code, other+": "+w.Body.String()) + require.Equal(t, before, upstream.calls) + slots.assertReleased(t) + } + c, _ = newContext(http.MethodGet) + key, _ := middleware.GetAPIKeyFromContext(c) + subject, _ := middleware.GetAuthSubjectFromContext(c) + result := &service.OpenAIForwardResult{Usage: service.OpenAIUsage{OutputTokens: 12345}, ResponseID: "seedance:task-ark"} + for i := range 20 { + billed := prepareSeedanceCompletionBilling(context.Background(), h, key, subject, result.ResponseID, result) + if i == 0 { + require.NotNil(t, billed) + require.Equal(t, "doubao-seedance", billed.BillingModel) + require.Equal(t, 12345, billed.Usage.OutputTokens) + require.Zero(t, billed.VideoCount) + } else { + require.Nil(t, billed) + } + } + require.Len(t, bindings.billed, 1) +} diff --git a/backend/internal/server/routes/gateway.go b/backend/internal/server/routes/gateway.go index bd6e5e7ebe..86f605993a 100644 --- a/backend/internal/server/routes/gateway.go +++ b/backend/internal/server/routes/gateway.go @@ -368,6 +368,11 @@ func RegisterGatewayRoutes( rootRoute := func(method, path string, limit gin.HandlerFunc, handler gin.HandlerFunc) { r.Handle(method, path, limit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), groupModelAllowlist, compositeTarget, requireGroupAnthropic, handler) } + for _, prefix := range []string{"/api/v3", "/v3", "/v1", ""} { + rootRoute(http.MethodPost, prefix+"/contents/generations/tasks", bodyLimit, h.OpenAIGateway.SeedanceTasks) + rootRoute(http.MethodGet, prefix+"/contents/generations/tasks/:task_id", bodyLimit, h.OpenAIGateway.SeedanceTasks) + rootRoute(http.MethodDelete, prefix+"/contents/generations/tasks/:task_id", bodyLimit, h.OpenAIGateway.SeedanceTasks) + } rootRoute(http.MethodPost, "/responses", bodyLimit, responsesHandler) rootRoute(http.MethodPost, "/responses/*subpath", bodyLimit, guardResponsesSubpath(responsesHandler)) rootRoute(http.MethodPost, "/alpha/search", textBodyLimit, h.OpenAIGateway.AlphaSearch) diff --git a/backend/internal/server/routes/gateway_model_allowlist_test.go b/backend/internal/server/routes/gateway_model_allowlist_test.go index 1fd154d709..85c5099342 100644 --- a/backend/internal/server/routes/gateway_model_allowlist_test.go +++ b/backend/internal/server/routes/gateway_model_allowlist_test.go @@ -157,6 +157,10 @@ func TestGatewayRoutesGroupModelAllowlistCoversRootAliasRoutes(t *testing.T) { {http.MethodPost, "/v1/embeddings", `{"model":"gpt-4.1","input":"hi"}`}, {http.MethodPost, "/v1/images/generations", `{"model":"gpt-4.1"}`}, {http.MethodPost, "/v1/videos/generations", `{"model":"gpt-4.1"}`}, + {http.MethodPost, "/api/v3/contents/generations/tasks", `{"model":"gpt-4.1","content":[{"type":"text","text":"waves"}]}`}, + {http.MethodPost, "/v3/contents/generations/tasks", `{"model":"gpt-4.1","content":[{"type":"text","text":"waves"}]}`}, + {http.MethodPost, "/v1/contents/generations/tasks", `{"model":"gpt-4.1","content":[{"type":"text","text":"waves"}]}`}, + {http.MethodPost, "/contents/generations/tasks", `{"model":"gpt-4.1","content":[{"type":"text","text":"waves"}]}`}, {http.MethodPost, "/v1/live", `{"session":{"model":"gpt-4.1"},"sdp":"v=0"}`}, {http.MethodPost, "/backend-api/codex/responses", `{"model":"gpt-4.1"}`}, {http.MethodPost, "/backend-api/codex/realtime/calls", `{"session":{"model":"gpt-4.1"},"sdp":"v=0"}`}, diff --git a/backend/internal/server/routes/seedance_test.go b/backend/internal/server/routes/seedance_test.go new file mode 100644 index 0000000000..301013f01a --- /dev/null +++ b/backend/internal/server/routes/seedance_test.go @@ -0,0 +1,34 @@ +package routes + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/stretchr/testify/require" +) + +func TestSeedanceNativeRoutes(t *testing.T) { + router := newGatewayRoutesTestRouter() + for _, prefix := range []string{"/api/v3", "/v3", "/v1", ""} { + for _, method := range []string{http.MethodPost, http.MethodGet, http.MethodDelete} { + path := prefix + "/contents/generations/tasks" + if method != http.MethodPost { + path += "/task-1" + } + w := httptest.NewRecorder() + router.ServeHTTP(w, httptest.NewRequest(method, path, strings.NewReader(`{"model":"seedance","content":[{"type":"text","text":"waves"}]}`))) + require.NotEqual(t, http.StatusNotFound, w.Code, method+" "+path) + } + } +} + +func TestSeedanceRejectsOtherPlatforms(t *testing.T) { + for _, platform := range []string{service.PlatformGrok, service.PlatformAnthropic, service.PlatformGemini} { + w := httptest.NewRecorder() + newGatewayRoutesTestRouter(platform).ServeHTTP(w, httptest.NewRequest(http.MethodPost, "/api/v3/contents/generations/tasks", strings.NewReader(`{"model":"seedance","content":[{}]}`))) + require.Equal(t, http.StatusForbidden, w.Code) + } +} diff --git a/backend/internal/service/account.go b/backend/internal/service/account.go index 484e3841b4..278d359c65 100644 --- a/backend/internal/service/account.go +++ b/backend/internal/service/account.go @@ -1828,6 +1828,11 @@ func (a *Account) SupportsOpenAIEndpointCapability(capability OpenAIEndpointCapa if a == nil { return false } + if capability == OpenAIEndpointCapabilitySeedance { + configured, _ := a.openAIEndpointCapabilitySet() + return configured["seedance"] && a.Platform == PlatformOpenAI && a.Type == AccountTypeAPIKey && + strings.TrimSpace(a.GetCredential("base_url")) != "" + } if capability == "" { return true } diff --git a/backend/internal/service/grok_media.go b/backend/internal/service/grok_media.go index 24b4dcb1b3..b73972ae58 100644 --- a/backend/internal/service/grok_media.go +++ b/backend/internal/service/grok_media.go @@ -41,10 +41,13 @@ func (e GrokMediaEndpoint) RequiresRequestBody() bool { } func (e GrokMediaEndpoint) IsVideoLookupRequest() bool { - return e == GrokMediaEndpointVideoStatus || e == GrokMediaEndpointVideoContent + return e == GrokMediaEndpointVideoStatus || e == GrokMediaEndpointVideoContent || e == SeedanceEndpointStatus || e == SeedanceEndpointDelete } func (e GrokMediaEndpoint) IsGenerationRequest() bool { + if e == SeedanceEndpointCreate { + return true + } switch e { case GrokMediaEndpointImagesGenerations, GrokMediaEndpointImagesEdits, GrokMediaEndpointVideosGenerations, GrokMediaEndpointVideosEdits, GrokMediaEndpointVideosExtensions: return true @@ -337,6 +340,12 @@ func (s *OpenAIGatewayService) ResolveGrokMediaVideoRequestAccount( // the ownership key; video lookups must neither escape nor refresh that key. func (s *OpenAIGatewayService) SelectGrokMediaVideoRequestAccount( ctx context.Context, groupID *int64, sessionHash string, accountID int64, requestedModel string, +) (*AccountSelectionResult, OpenAIAccountScheduleDecision, error) { + return s.SelectMediaVideoRequestAccount(ctx, groupID, sessionHash, accountID, requestedModel, PlatformGrok) +} + +func (s *OpenAIGatewayService) SelectMediaVideoRequestAccount( + ctx context.Context, groupID *int64, sessionHash string, accountID int64, requestedModel, platform string, ) (*AccountSelectionResult, OpenAIAccountScheduleDecision, error) { decision := OpenAIAccountScheduleDecision{Layer: openAIAccountScheduleLayerSessionSticky} if accountID <= 0 || strings.TrimSpace(sessionHash) == "" { @@ -345,7 +354,7 @@ func (s *OpenAIGatewayService) SelectGrokMediaVideoRequestAccount( ctx = s.withOpenAIGroupPrivacyRequirement(WithOpenAIProfitControlSuppressed(ctx), groupID) scheduler := &defaultOpenAIAccountScheduler{service: s} selection, _, err := scheduler.selectBySessionHash(ctx, OpenAIAccountScheduleRequest{ - GroupID: groupID, Platform: PlatformGrok, SessionHash: sessionHash, + GroupID: groupID, Platform: platform, SessionHash: sessionHash, StickyAccountID: accountID, PreserveStickyBinding: true, DisableStickyEscape: true, RequestedModel: requestedModel, RequiredTransport: OpenAIUpstreamTransportHTTPSSE, RequirePrivacySet: s.openAIGroupRequiresPrivacySet(ctx, groupID), diff --git a/backend/internal/service/seedance.go b/backend/internal/service/seedance.go new file mode 100644 index 0000000000..faa83da49e --- /dev/null +++ b/backend/internal/service/seedance.go @@ -0,0 +1,154 @@ +package service + +import ( + "bytes" + "context" + "fmt" + "net/http" + "strings" + "time" + + "github.com/gin-gonic/gin" + "github.com/tidwall/gjson" + "github.com/tidwall/sjson" +) + +const ( + SeedanceEndpointCreate GrokMediaEndpoint = "seedance_create" + SeedanceEndpointStatus GrokMediaEndpoint = "seedance_status" + SeedanceEndpointDelete GrokMediaEndpoint = "seedance_delete" + OpenAIEndpointCapabilitySeedance OpenAIEndpointCapability = "seedance" +) + +func (e GrokMediaEndpoint) IsSeedance() bool { + return e == SeedanceEndpointCreate || e == SeedanceEndpointStatus || e == SeedanceEndpointDelete +} + +// SeedanceTaskKey isolates ownership and billing keys from other video providers. +func SeedanceTaskKey(id string) string { return "seedance:" + strings.TrimSpace(id) } + +func ParseSeedanceRequest(body []byte) (GrokMediaRequestInfo, error) { + var info GrokMediaRequestInfo + if !gjson.ValidBytes(body) || !gjson.ParseBytes(body).IsObject() { + return info, fmt.Errorf("request body must be a JSON object") + } + model := gjson.GetBytes(body, "model") + if model.Type != gjson.String || strings.TrimSpace(model.String()) == "" { + return info, fmt.Errorf("model is required") + } + content := gjson.GetBytes(body, "content") + if !content.IsArray() || len(content.Array()) == 0 { + return info, fmt.Errorf("content must be a non-empty array") + } + info.Model = strings.TrimSpace(model.String()) + var texts []string + for _, item := range content.Array() { + switch item.Get("type").String() { + case "text": + texts = append(texts, item.Get("text").String()) + case "image_url": + info.InputImageURLs = append(info.InputImageURLs, item.Get("image_url.url").String()) + } + } + info.Prompt = strings.Join(texts, "\n") + return info, nil +} + +func buildSeedanceURL(base string, endpoint GrokMediaEndpoint, taskID string) (string, error) { + base = strings.TrimRight(base, "/") + // Accept an origin, a proxy prefix, or the full Ark API base. + if !strings.HasSuffix(base, "/api/v3") && !strings.HasSuffix(base, "/v3") { + base += "/api/v3" + } + base += "/contents/generations/tasks" + if endpoint != SeedanceEndpointCreate { + if err := validateUpstreamPathSegment("Seedance task ID", taskID); err != nil || strings.TrimSpace(taskID) == "" { + return "", fmt.Errorf("invalid Seedance task ID") + } + base += "/" + taskID + } + return base, nil +} + +// ForwardSeedance preserves the Ark protocol, including multimodal content and +// future fields. Only model is rewritten using the account's configured mapping. +func (s *OpenAIGatewayService) ForwardSeedance(ctx context.Context, c *gin.Context, account *Account, endpoint GrokMediaEndpoint, taskID string, body []byte) (*OpenAIForwardResult, error) { + if !account.SupportsOpenAIEndpointCapability(OpenAIEndpointCapabilitySeedance) || !endpoint.IsSeedance() { + return nil, fmt.Errorf("Seedance requires an OpenAI API key account with a custom base URL") + } + base, err := s.validateUpstreamBaseURL(account.GetCredential("base_url")) + if err != nil { + return nil, err + } + target, err := buildSeedanceURL(base, endpoint, strings.TrimPrefix(taskID, "seedance:")) + if err != nil { + return nil, err + } + model, upstreamModel := "", "" + method := http.MethodGet + if endpoint == SeedanceEndpointCreate { + info, parseErr := ParseSeedanceRequest(body) + if parseErr != nil { + return nil, parseErr + } + model = info.Model + upstreamModel = account.GetMappedModel(model) + body, err = sjson.SetBytes(body, "model", upstreamModel) + if err != nil { + return nil, err + } + method = http.MethodPost + } else if endpoint == SeedanceEndpointDelete { + method = http.MethodDelete + } + token := strings.TrimSpace(account.GetCredential("api_key")) + if token == "" { + return nil, fmt.Errorf("Seedance account missing api_key") + } + started := time.Now() + req, err := http.NewRequestWithContext(ctx, method, target, bytes.NewReader(body)) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + account.ApplyHeaderOverrides(req.Header) + proxy := "" + if account.ProxyID != nil && account.Proxy != nil { + proxy = account.Proxy.URL() + } + resp, err := s.httpUpstream.Do(req, proxy, account.ID, account.Concurrency) + SetOpsLatencyMs(c, OpsUpstreamLatencyMsKey, time.Since(started).Milliseconds()) + if err != nil { + return nil, err + } + defer resp.Body.Close() + responseBody, err := ReadUpstreamResponseBody(resp.Body, s.cfg, c, openAITooLargeError) + if err != nil { + return nil, err + } + // Do not retry ambiguous asynchronous creates: the upstream may already have + // accepted a billable job. Preserve native error codes and response bodies. + if resp.StatusCode >= 300 { + writeGrokMediaResponse(c, resp, responseBody, s.responseHeaderFilter) + return nil, fmt.Errorf("Seedance upstream status %d", resp.StatusCode) + } + result := &OpenAIForwardResult{Model: model, BillingModel: model, UpstreamModel: upstreamModel, Duration: time.Since(started), ResponseHeaders: resp.Header.Clone()} + if endpoint == SeedanceEndpointCreate { + id := strings.TrimSpace(gjson.GetBytes(responseBody, "id").String()) + if id == "" { + return nil, fmt.Errorf("Seedance create response missing task ID") + } + result.ResponseID = SeedanceTaskKey(id) + } + if endpoint == SeedanceEndpointStatus { + result.ResponseID = taskID + result.UpstreamModel = gjson.GetBytes(responseBody, "model").String() + if gjson.GetBytes(responseBody, "status").String() == "succeeded" { + result.Usage.OutputTokens = max(0, int(gjson.GetBytes(responseBody, "usage.completion_tokens").Int())) + } + } + writeGrokMediaResponse(c, resp, responseBody, s.responseHeaderFilter) + return result, nil +} diff --git a/backend/internal/service/seedance_test.go b/backend/internal/service/seedance_test.go new file mode 100644 index 0000000000..cc9504b1eb --- /dev/null +++ b/backend/internal/service/seedance_test.go @@ -0,0 +1,112 @@ +//go:build unit + +package service + +import ( + "context" + "io" + "net/http" + "testing" + + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +func seedanceTestAccount() *Account { + return &Account{ID: 9, Platform: PlatformOpenAI, Type: AccountTypeAPIKey, Credentials: map[string]any{ + "api_key": "ark-secret", "base_url": "https://ark.cn-beijing.volces.com/api/v3", + "openai_capabilities": []string{"seedance"}, + "model_mapping": map[string]any{"video": "ep-seedance"}, + }} +} + +func TestSeedanceNativeForwarding(t *testing.T) { + body := []byte(`{"model":"video","content":[{"type":"text","text":"waves"},{"type":"image_url","image_url":{"url":"https://example.com/first.png"},"role":"first_frame"},{"type":"audio_url","audio_url":{"url":"https://example.com/audio.mp3"}}],"duration":-1,"generate_audio":true,"future_field":{"keep":true}}`) + upstream := &grokMediaContentUpstreamStub{response: grokMediaContentStatusResponse(`{"id":"task-1"}`)} + svc := &OpenAIGatewayService{httpUpstream: upstream} + c, w := grokMediaContentTestContext(http.MethodPost, "/api/v3/contents/generations/tasks", nil) + result, err := svc.ForwardSeedance(context.Background(), c, seedanceTestAccount(), SeedanceEndpointCreate, "", body) + require.NoError(t, err) + require.JSONEq(t, `{"id":"task-1"}`, w.Body.String()) + require.Equal(t, "seedance:task-1", result.ResponseID) + require.Zero(t, result.Usage.OutputTokens) + require.Equal(t, "video", result.BillingModel) + require.Equal(t, "ep-seedance", result.UpstreamModel) + require.Equal(t, "https://ark.cn-beijing.volces.com/api/v3/contents/generations/tasks", upstream.request.URL.String()) + require.Equal(t, "Bearer ark-secret", upstream.request.Header.Get("Authorization")) + forwarded, err := io.ReadAll(upstream.request.Body) + require.NoError(t, err) + require.Equal(t, "ep-seedance", gjson.GetBytes(forwarded, "model").String()) + for _, field := range []string{"content", "duration", "generate_audio", "future_field"} { + require.Equal(t, gjson.GetBytes(body, field).Raw, gjson.GetBytes(forwarded, field).Raw) + } +} + +func TestSeedanceStatusAndDelete(t *testing.T) { + for _, status := range []string{"queued", "running", "failed", "cancelled", "expired", "succeeded"} { + t.Run(status, func(t *testing.T) { + body := `{"id":"task-1","status":"` + status + `","model":"ep-seedance","content":{"video_url":"https://cdn.example/video.mp4"},"usage":{"completion_tokens":12345}}` + upstream := &grokMediaContentUpstreamStub{response: grokMediaContentStatusResponse(body)} + svc := &OpenAIGatewayService{httpUpstream: upstream} + c, w := grokMediaContentTestContext(http.MethodGet, "/api/v3/contents/generations/tasks/task-1", nil) + result, err := svc.ForwardSeedance(context.Background(), c, seedanceTestAccount(), SeedanceEndpointStatus, "seedance:task-1", nil) + require.NoError(t, err) + require.JSONEq(t, body, w.Body.String()) + require.Equal(t, "/api/v3/contents/generations/tasks/task-1", upstream.request.URL.Path) + if status == "succeeded" { + require.Equal(t, 12345, result.Usage.OutputTokens) + } else { + require.Zero(t, result.Usage.OutputTokens) + } + require.Zero(t, result.VideoCount, "must use token billing, not Grok seconds") + }) + } + upstream := &grokMediaContentUpstreamStub{response: grokMediaContentStatusResponse("")} + upstream.response.StatusCode = http.StatusNoContent + svc := &OpenAIGatewayService{httpUpstream: upstream} + c, w := grokMediaContentTestContext(http.MethodDelete, "/api/v3/contents/generations/tasks/task-1", nil) + _, err := svc.ForwardSeedance(context.Background(), c, seedanceTestAccount(), SeedanceEndpointDelete, "seedance:task-1", nil) + require.NoError(t, err) + require.Equal(t, http.MethodDelete, upstream.request.Method) + require.Equal(t, http.StatusNoContent, w.Code) +} + +func TestSeedanceValidationAndCapability(t *testing.T) { + for _, body := range []string{`{`, `[]`, `{}`, `{"model":12,"content":[{}]}`, `{"model":"x","content":[]}`} { + _, err := ParseSeedanceRequest([]byte(body)) + require.Error(t, err) + } + info, err := ParseSeedanceRequest([]byte(`{"model":"x","content":[{"type":"text","text":"first"},{"type":"text","text":"second"},{"type":"image_url","image_url":{"url":"https://example.com/a.png"}}]}`)) + require.NoError(t, err) + require.Contains(t, string(info.ModerationBody()), "first") + require.Contains(t, string(info.ModerationBody()), "second") + require.Contains(t, string(info.ModerationBody()), "https://example.com/a.png") + for _, id := range []string{"", "..", "a/b", "a?b", "a#b", "%2e%2e"} { + _, err := buildSeedanceURL("https://example.com", SeedanceEndpointStatus, id) + require.Error(t, err, id) + } + for _, base := range []string{"https://example.com", "https://example.com/api/v3/", "https://example.com/v3"} { + url, err := buildSeedanceURL(base, SeedanceEndpointCreate, "") + require.NoError(t, err) + require.NotContains(t, url, "/v3/api/v3") + } + a := seedanceTestAccount() + require.True(t, a.SupportsOpenAIEndpointCapability(OpenAIEndpointCapabilitySeedance)) + a.Type = AccountTypeOAuth + require.False(t, a.SupportsOpenAIEndpointCapability(OpenAIEndpointCapabilitySeedance)) + a.Type = AccountTypeAPIKey + delete(a.Credentials, "openai_capabilities") + require.False(t, a.SupportsOpenAIEndpointCapability(OpenAIEndpointCapabilitySeedance)) +} + +func TestSeedancePreservesUpstreamErrorsWithoutRetry(t *testing.T) { + upstream := &grokMediaContentUpstreamStub{response: grokMediaContentStatusResponse(`{"error":{"code":"QuotaExceeded","message":"quota exhausted"}}`)} + upstream.response.StatusCode = 429 + svc := &OpenAIGatewayService{httpUpstream: upstream} + c, w := grokMediaContentTestContext(http.MethodPost, "/api/v3/contents/generations/tasks", nil) + _, err := svc.ForwardSeedance(context.Background(), c, seedanceTestAccount(), SeedanceEndpointCreate, "", []byte(`{"model":"video","content":[{"type":"text","text":"waves"}]}`)) + require.Error(t, err) + require.Equal(t, 429, w.Code) + require.Contains(t, w.Body.String(), "QuotaExceeded") + require.Len(t, upstream.requests, 1) +} diff --git a/docs/seedance-api.md b/docs/seedance-api.md new file mode 100644 index 0000000000..4480b16e70 --- /dev/null +++ b/docs/seedance-api.md @@ -0,0 +1,45 @@ +# Seedance 原生 API + +支持火山方舟 Ark 的异步视频任务协议,无需把 `content[]` 转换成 OpenAI `messages` 或 Grok `prompt`。 + +## 配置 + +1. 创建 OpenAI 平台的 **API Key** 账号,填写 Ark API Key,Base URL 使用 `https://ark.cn-beijing.volces.com/api/v3`。兼容服务可填写自己的 `/api/v3` 或 `/v3` Base URL。 +2. 在账号的端点能力中勾选 **Seedance (Ark)**。默认不启用,避免请求误调度到其他 OpenAI 账号。支持创建、编辑与批量编辑。 +3. 将账号加入 OpenAI 分组并启用分组的「允许图片生成」媒体权限;合成分组也可路由到这些账号。 +4. 配置模型映射,例如将公开模型名 `seedance-video` 映射到实际 `doubao-seedance-*` 模型或 `ep-*` 推理接入点。配置对应模型的输出 token 价格;本接口不使用 Grok 的按秒视频价格。 + +## 调用 + +```bash +curl "$SUB2API_BASE_URL/api/v3/contents/generations/tasks" \ + -H "Authorization: Bearer $SUB2API_KEY" \ + -H 'Content-Type: application/json' \ + -d '{ + "model": "seedance-video", + "content": [{"type": "text", "text": "海浪轻轻拍打沙滩"}], + "duration": 5, + "resolution": "720p", + "ratio": "16:9", + "generate_audio": true + }' + +# 使用创建响应中的原生 id 查询,直至 succeeded / failed / cancelled 等终态。 +curl "$SUB2API_BASE_URL/api/v3/contents/generations/tasks/$TASK_ID" \ + -H "Authorization: Bearer $SUB2API_KEY" + +curl -X DELETE "$SUB2API_BASE_URL/api/v3/contents/generations/tasks/$TASK_ID" \ + -H "Authorization: Bearer $SUB2API_KEY" +``` + +亦支持 `/v3`、`/v1` 和无版本前缀别名。Ark SDK 的 Base URL 可改为 `$SUB2API_BASE_URL/api/v3`。文本、图片、视频、音频内容、角色及扩展参数原样传递,仅按账号配置改写模型名;响应保持上游原生格式。 + +## 任务与计费 + +- 查询和删除只能访问同一用户、同一 API Key、同一分组创建的任务,并始终使用原提交账号;不会转到其他账号查询。 +- 创建时不扣 token 用量。首次查询到 `succeeded` 后,根据上游 `usage.completion_tokens` 计费;重复查询由共享缓存声明和持久化用量去重共同保护。失败、排队及运行中的任务不计费。 +- Redis 保存任务绑定及创建时的模型快照,默认 24 小时。需保留 Redis 状态并在有效期内查询完成结果。当前不会后台轮询;只使用回调而不查询的任务不会自动结算。 +- 不开放上游的任务列表接口,防止共享账号的任务泄露给其他用户。删除遵循上游语义,不自动退款。 +- 异步创建的上游错误不自动重试,以免重复创建付费任务。 + +协议依据:[火山官方 Go SDK](https://github.com/volcengine/volcengine-go-sdk/blob/master/service/arkruntime/model/content_generation.go)、[创建任务文档](https://www.volcengine.com/docs/82379/1520757)、[查询任务文档](https://www.volcengine.com/docs/82379/1521309)。 diff --git a/frontend/src/components/account/BulkEditAccountModal.vue b/frontend/src/components/account/BulkEditAccountModal.vue index f66fb85fda..22f6f3e788 100644 --- a/frontend/src/components/account/BulkEditAccountModal.vue +++ b/frontend/src/components/account/BulkEditAccountModal.vue @@ -1786,7 +1786,8 @@ const openAIEndpointCapabilityOptions = computed< Array<{ value: OpenAIEndpointCapability; label: string }> >(() => [ { value: 'chat_completions', label: openAITextEndpointCapabilityLabel.value }, - { value: 'embeddings', label: t('admin.accounts.openai.capabilityEmbeddings') } + { value: 'embeddings', label: t('admin.accounts.openai.capabilityEmbeddings') }, + { value: 'seedance', label: 'Seedance (Ark)' } ]) const openAITextGenerationCapabilityEnabled = computed(() => openAIEndpointCapabilities.value.includes('chat_completions') @@ -1796,9 +1797,9 @@ const openAIResponsesModeApplicable = computed( ) const normalizeOpenAIEndpointCapabilities = (values: OpenAIEndpointCapability[]) => { - const allowed: OpenAIEndpointCapability[] = ['chat_completions', 'embeddings'] + const allowed: OpenAIEndpointCapability[] = ['chat_completions', 'embeddings', 'seedance'] const selected = allowed.filter((value) => values.includes(value)) - return selected.length > 0 ? selected : allowed + return selected.length > 0 ? selected : ['chat_completions', 'embeddings'] as OpenAIEndpointCapability[] } const toggleOpenAIEndpointCapability = ( @@ -1996,7 +1997,7 @@ const buildUpdatePayload = (): Record | null => { if (applyOpenAIEndpointCapabilities) { credentials.openai_capabilities = - openAIEndpointCapabilities.value.length === 2 + openAIEndpointCapabilities.value.length === 2 && !openAIEndpointCapabilities.value.includes('seedance') ? null : [...openAIEndpointCapabilities.value] credentialsChanged = true diff --git a/frontend/src/components/account/CreateAccountModal.vue b/frontend/src/components/account/CreateAccountModal.vue index 54a6b2331f..c6616c679f 100644 --- a/frontend/src/components/account/CreateAccountModal.vue +++ b/frontend/src/components/account/CreateAccountModal.vue @@ -4524,16 +4524,17 @@ const openAITextEndpointCapabilityLabel = computed(() => { }) const openAIEndpointCapabilityOptions = computed<{ value: OpenAIEndpointCapability; label: string }[]>(() => [ { value: 'chat_completions', label: openAITextEndpointCapabilityLabel.value }, - { value: 'embeddings', label: t('admin.accounts.openai.capabilityEmbeddings') } + { value: 'embeddings', label: t('admin.accounts.openai.capabilityEmbeddings') }, + { value: 'seedance', label: 'Seedance (Ark)' } ]) const openAITextGenerationCapabilityEnabled = computed(() => openAIEndpointCapabilities.value.includes('chat_completions') ) const normalizeOpenAIEndpointCapabilities = (values: OpenAIEndpointCapability[]) => { - const allowed: OpenAIEndpointCapability[] = ['chat_completions', 'embeddings'] + const allowed: OpenAIEndpointCapability[] = ['chat_completions', 'embeddings', 'seedance'] const selected = allowed.filter((value) => values.includes(value)) - return selected.length > 0 ? selected : allowed + return selected.length > 0 ? selected : ['chat_completions', 'embeddings'] as OpenAIEndpointCapability[] } const toggleOpenAIEndpointCapability = (capability: OpenAIEndpointCapability, event?: Event) => { @@ -4559,7 +4560,7 @@ const toggleOpenAIEndpointCapability = (capability: OpenAIEndpointCapability, ev const applyOpenAIEndpointCapabilities = (credentials: Record) => { const capabilities = normalizeOpenAIEndpointCapabilities(openAIEndpointCapabilities.value) - if (capabilities.length === 2) { + if (capabilities.length === 2 && !capabilities.includes('seedance')) { delete credentials.openai_capabilities return } diff --git a/frontend/src/components/account/EditAccountModal.vue b/frontend/src/components/account/EditAccountModal.vue index 0d3c235bea..4e9bbff7a1 100644 --- a/frontend/src/components/account/EditAccountModal.vue +++ b/frontend/src/components/account/EditAccountModal.vue @@ -3683,16 +3683,17 @@ const openAITextEndpointCapabilityLabel = computed(() => { }) const openAIEndpointCapabilityOptions = computed<{ value: OpenAIEndpointCapability; label: string }[]>(() => [ { value: 'chat_completions', label: openAITextEndpointCapabilityLabel.value }, - { value: 'embeddings', label: t('admin.accounts.openai.capabilityEmbeddings') } + { value: 'embeddings', label: t('admin.accounts.openai.capabilityEmbeddings') }, + { value: 'seedance', label: 'Seedance (Ark)' } ]) const openAITextGenerationCapabilityEnabled = computed(() => openAIEndpointCapabilities.value.includes('chat_completions') ) const normalizeOpenAIEndpointCapabilities = (values: OpenAIEndpointCapability[]) => { - const allowed: OpenAIEndpointCapability[] = ['chat_completions', 'embeddings'] + const allowed: OpenAIEndpointCapability[] = ['chat_completions', 'embeddings', 'seedance'] const selected = allowed.filter((value) => values.includes(value)) - return selected.length > 0 ? selected : allowed + return selected.length > 0 ? selected : ['chat_completions', 'embeddings'] as OpenAIEndpointCapability[] } const readOpenAIEndpointCapabilities = (credentials?: Record): OpenAIEndpointCapability[] => { @@ -3700,7 +3701,7 @@ const readOpenAIEndpointCapabilities = (credentials?: Record): if (Array.isArray(raw)) { return normalizeOpenAIEndpointCapabilities( raw.filter((value): value is OpenAIEndpointCapability => - value === 'chat_completions' || value === 'embeddings' + value === 'chat_completions' || value === 'embeddings' || value === 'seedance' ) ) } @@ -3738,7 +3739,7 @@ const toggleOpenAIEndpointCapability = (capability: OpenAIEndpointCapability, ev const applyOpenAIEndpointCapabilities = (credentials: Record) => { const capabilities = normalizeOpenAIEndpointCapabilities(openAIEndpointCapabilities.value) - if (capabilities.length === 2) { + if (capabilities.length === 2 && !capabilities.includes('seedance')) { delete credentials.openai_capabilities return } diff --git a/frontend/src/components/account/__tests__/BulkEditAccountModal.spec.ts b/frontend/src/components/account/__tests__/BulkEditAccountModal.spec.ts index 817f0a729d..daae21b9f9 100644 --- a/frontend/src/components/account/__tests__/BulkEditAccountModal.spec.ts +++ b/frontend/src/components/account/__tests__/BulkEditAccountModal.spec.ts @@ -557,6 +557,18 @@ describe('BulkEditAccountModal', () => { }) }) + it('persists Seedance in a two-capability bulk update', async () => { + const wrapper = mountModal({ selectedPlatforms: ['openai'], selectedTypes: ['apikey'] }) + await wrapper.get('#bulk-edit-openai-endpoint-capabilities-enabled').setValue(true) + await wrapper.get('[data-testid="bulk-edit-openai-endpoint-capability-embeddings"]').setValue(false) + await wrapper.get('[data-testid="bulk-edit-openai-endpoint-capability-seedance"]').setValue(true) + await wrapper.get('#bulk-edit-account-form').trigger('submit.prevent') + await flushPromises() + expect(adminAPI.accounts.bulkUpdate).toHaveBeenCalledWith([1, 2], { + credentials: { openai_capabilities: ['chat_completions', 'seedance'] } + }) + }) + it('关闭端点能力修改后 Responses 路由恢复独立可编辑', async () => { const wrapper = mountModal({ selectedPlatforms: ['openai'], diff --git a/frontend/src/components/account/__tests__/EditAccountModal.spec.ts b/frontend/src/components/account/__tests__/EditAccountModal.spec.ts index 6a16c3f22f..7b104410c1 100644 --- a/frontend/src/components/account/__tests__/EditAccountModal.spec.ts +++ b/frontend/src/components/account/__tests__/EditAccountModal.spec.ts @@ -1311,6 +1311,19 @@ describe('EditAccountModal', () => { expect(updateAccountMock.mock.calls[0]?.[1]?.extra?.auto_pause_7d_disabled).toBeUndefined() }) + it('preserves Seedance when exactly two endpoint capabilities are selected', async () => { + const account = buildAccount() + account.credentials.openai_capabilities = ['chat_completions', 'seedance'] + updateAccountMock.mockReset() + checkMixedChannelRiskMock.mockReset() + checkMixedChannelRiskMock.mockResolvedValue({ has_risk: false }) + updateAccountMock.mockResolvedValue(account) + const wrapper = mountModal(account) + expect(wrapper.get('[data-testid="openai-endpoint-capability-seedance"]').element.checked).toBe(true) + await wrapper.get('form#edit-account-form').trigger('submit.prevent') + expect(updateAccountMock.mock.calls[0]?.[1]?.credentials?.openai_capabilities).toEqual(['chat_completions', 'seedance']) + }) + it('keeps at least one OpenAI APIKey endpoint capability selected', async () => { const account = buildAccount() updateAccountMock.mockReset() diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 60eb7e5802..3ae9838133 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -1455,7 +1455,7 @@ export interface CodexUsageSnapshot { export type OpenAICompactMode = 'auto' | 'force_on' | 'force_off' export type OpenAIResponsesMode = 'auto' | 'force_responses' | 'force_chat_completions' -export type OpenAIEndpointCapability = 'chat_completions' | 'embeddings' +export type OpenAIEndpointCapability = 'chat_completions' | 'embeddings' | 'seedance' export interface OpenAICompactState { openai_compact_mode?: OpenAICompactMode