From aba34524f1d52c4f1b9fb59886fa6f55d70ef3ee Mon Sep 17 00:00:00 2001 From: Cai Date: Thu, 17 Sep 2026 04:14:14 +0900 Subject: [PATCH 1/2] 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 From d099c06945523ab9fe4124a0f1a4ed3f1c83e289 Mon Sep 17 00:00:00 2001 From: Cai Date: Thu, 17 Sep 2026 17:51:03 +0900 Subject: [PATCH 2/2] fix: resolve Seedance lint and gRPC security checks --- backend/go.mod | 28 ++++----- backend/go.sum | 85 ++++++++++++---------------- backend/internal/service/seedance.go | 15 ++--- 3 files changed, 58 insertions(+), 70 deletions(-) diff --git a/backend/go.mod b/backend/go.mod index 3cd44df4d9..941f519d08 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -50,13 +50,13 @@ require ( github.com/wechatpay-apiv3/wechatpay-go v0.2.21 github.com/zeromicro/go-zero v1.9.4 go.uber.org/zap v1.24.0 - golang.org/x/crypto v0.53.0 + golang.org/x/crypto v0.55.0 golang.org/x/image v0.41.0 - golang.org/x/mod v0.37.0 - golang.org/x/net v0.56.0 - golang.org/x/sync v0.21.0 - golang.org/x/term v0.44.0 - google.golang.org/grpc v1.82.1 + golang.org/x/mod v0.38.0 + golang.org/x/net v0.58.0 + golang.org/x/sync v0.22.0 + golang.org/x/term v0.45.0 + google.golang.org/grpc v1.83.2 google.golang.org/protobuf v1.36.11 gopkg.in/natefinch/lumberjack.v2 v2.2.1 gopkg.in/yaml.v3 v3.0.1 @@ -192,20 +192,20 @@ require ( github.com/zclconf/go-cty v1.14.4 // indirect github.com/zclconf/go-cty-yaml v1.1.0 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect - go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 // indirect - go.opentelemetry.io/otel v1.43.0 // indirect - go.opentelemetry.io/otel/metric v1.43.0 // indirect - go.opentelemetry.io/otel/trace v1.43.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect + go.opentelemetry.io/otel v1.44.0 // indirect + go.opentelemetry.io/otel/metric v1.44.0 // indirect + go.opentelemetry.io/otel/trace v1.44.0 // indirect go.uber.org/atomic v1.11.0 // indirect go.uber.org/automaxprocs v1.6.0 // indirect go.uber.org/multierr v1.9.0 // indirect golang.org/x/arch v0.3.0 // indirect golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect - golang.org/x/sys v0.46.0 // indirect - golang.org/x/text v0.39.0 // indirect + golang.org/x/sys v0.47.0 // indirect + golang.org/x/text v0.41.0 // indirect golang.org/x/time v0.12.0 // indirect - golang.org/x/tools v0.47.0 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 // indirect + golang.org/x/tools v0.48.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect gopkg.in/ini.v1 v1.67.0 // indirect modernc.org/libc v1.67.6 // indirect modernc.org/mathutil v1.7.1 // indirect diff --git a/backend/go.sum b/backend/go.sum index f639504856..caf01c3b6b 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -253,8 +253,6 @@ github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba/go.mod h1:E github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= -github.com/google/subcommands v1.2.0 h1:vWQspBTo2nEqTUFita5/KeEWlUL8kQObDFbub/EN9oE= -github.com/google/subcommands v1.2.0/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/wire v0.7.0 h1:JxUKI6+CVBgCO2WToKy/nQk0sS+amI9z9EjVmdaocj4= @@ -300,8 +298,6 @@ github.com/kisielk/sqlstruct v0.0.0-20201105191214-5f3e10d3ab46/go.mod h1:yyMNCy github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk= github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= -github.com/klauspost/cpuid/v2 v2.2.4 h1:acbojRNwl3o09bUq+yDCtZFc1aiwaAAxtcn8YkZXnvk= -github.com/klauspost/cpuid/v2 v2.2.4/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY= github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE= github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= @@ -329,8 +325,6 @@ github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27k github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= -github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mattn/go-sqlite3 v1.14.17 h1:mCRHCLDUBXgpKAqIKsaAaAsrAlbkeomtRFKXh2L6YIM= github.com/mattn/go-sqlite3 v1.14.17/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= github.com/mdelapenya/tlscert v0.2.0 h1:7H81W6Z/4weDvZBNOfQte5GpIMo0lGYEeWbkGp5LJHI= @@ -369,8 +363,6 @@ github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJm github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= github.com/oklog/run v1.1.0 h1:GEenZ1cK0+q0+wsJew9qUg/DyD8k3JzYsZAi5gYi2mA= github.com/oklog/run v1.1.0/go.mod h1:sVPdnTZT1zYwAJeCMu2Th4T21pA3FPOQRfWjQlk7DVU= -github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec= -github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= @@ -405,8 +397,6 @@ github.com/refraction-networking/utls v1.8.2 h1:j4Q1gJj0xngdeH+Ox/qND11aEfhpgoEv github.com/refraction-networking/utls v1.8.2/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= -github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= -github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= @@ -442,8 +432,6 @@ github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8= github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY= github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0= github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= -github.com/spf13/cobra v1.7.0 h1:hyqWnYt1ZQShIddO5kBpj3vu05/++x6tJ6dg8EC572I= -github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0= github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/viper v1.18.2 h1:LUXCnvUvSM6FXAsj6nnfc8Q2tp1dIgUfY9Kc8GsSOiQ= @@ -521,30 +509,30 @@ github.com/zclconf/go-cty v1.14.4 h1:uXXczd9QDGsgu0i/QFR/hzI5NYCHLf6NQw/atrbnhq8 github.com/zclconf/go-cty v1.14.4/go.mod h1:VvMs5i0vgZdhYawQNq5kePSpLAoz8u1xvZgrPIxfnZE= github.com/zclconf/go-cty-yaml v1.1.0 h1:nP+jp0qPHv2IhUVqmQSzjvqAWcObN0KBkUl2rWBdig0= github.com/zclconf/go-cty-yaml v1.1.0/go.mod h1:9YLUH4g7lOhVWqUbctnVlZ5KLpg7JAprQNgxSZ1Gyxs= +github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs= +github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s= github.com/zeromicro/go-zero v1.9.4 h1:aRLFoISqAYijABtkbliQC5SsI5TbizJpQvoHc9xup8k= github.com/zeromicro/go-zero v1.9.4/go.mod h1:a17JOTch25SWxBcUgJZYps60hygK3pIYdw7nGwlcS38= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 h1:jq9TW8u3so/bN+JPT166wjOI6/vQPF6Xe7nMNIltagk= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0/go.mod h1:p8pYQP+m5XfbZm9fxtSKAbM6oIllS7s2AfxrChvc7iw= -go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= -go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q= +go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= +go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.24.0 h1:t6wl9SPayj+c7lEIFgm4ooDBZVb01IhLB4InpomhRw8= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.24.0/go.mod h1:iSDOcsnSA5INXzZtwaBPrKp/lWu/V14Dd+llD0oI2EA= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.24.0 h1:Xw8U6u2f8DK2XAkGRFV7BBLENgnTGX9i4rQRxJf+/vs= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.24.0/go.mod h1:6KW1Fm6R/s6Z3PGXwSJN2K4eT6wQB3vXX6CVnYX9NmM= -go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM= -go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY= -go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg= -go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg= -go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw= -go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A= -go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= -go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= +go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc= +go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo= +go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58= +go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0= +go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI= +go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA= +go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= +go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= go.opentelemetry.io/proto/otlp v1.3.1 h1:TrMUixzpM0yuc/znrFTP9MMRh8trP93mkCiDVeXrui0= go.opentelemetry.io/proto/otlp v1.3.1/go.mod h1:0X1WI4de4ZsLrrJNLAQbFeLCm3T7yBkR0XqQ7niQU+8= -go.uber.org/atomic v1.10.0 h1:9qC72Qh0+3MqyJbAn8YU5xVq1frD8bn3JtD2oXtafVQ= -go.uber.org/atomic v1.10.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs= @@ -574,8 +562,8 @@ golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDf golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs= golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM= -golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= -golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= +golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M= +golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY= golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70= @@ -590,8 +578,8 @@ golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= -golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= -golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= +golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk= +golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -613,8 +601,8 @@ golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= golang.org/x/net v0.23.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg= golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE= -golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= -golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= +golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To= +golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -626,8 +614,8 @@ golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= -golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -645,7 +633,6 @@ golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -660,8 +647,8 @@ golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= -golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= @@ -674,8 +661,8 @@ golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= golang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58= golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= golang.org/x/term v0.21.0/go.mod h1:ooXLefLobQVslOqselCNF4SxFAaoS6KujMbsGzSDmX0= -golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc= -golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y= +golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= +golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= @@ -686,8 +673,8 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= -golang.org/x/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus= -golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM= +golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= +golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -702,8 +689,8 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= -golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= -golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= +golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE= +golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= @@ -714,16 +701,16 @@ google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7 google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= google.golang.org/genproto v0.0.0-20231106174013-bbf56f31fb17 h1:wpZ8pe2x1Q3f2KyT5f8oP/fa9rHAKgFPr/HZdNuS+PQ= -google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478 h1:yQugLulqltosq0B/f8l4w9VryjV+N/5gcW0jQ3N8Qec= -google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478/go.mod h1:C6ADNqOxbgdUUeRTU+LCHDPB9ttAMCTff6auwCVa4uc= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 h1:RmoJA1ujG+/lRGNfUnOMfhCy5EipVMyvUE+KNbPbTlw= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa h1:Kjn0N0tCrDgiAFW+lGO4JZ3ck44CehvJQMAwj9QF0G8= +google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:q4lMZS6kskjT5HvCPrnnypcDPVJqT/f4nfxmkE7gryY= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= -google.golang.org/grpc v1.82.1 h1:NnAxzGRA0677vCa4BUkOAnO5+FfQqVl9iUXeD0IqcGE= -google.golang.org/grpc v1.82.1/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA= +google.golang.org/grpc v1.83.2 h1:EManeRomTObA0BU7I8vXgg/78uE5MJ9M8B39EX2WscU= +google.golang.org/grpc v1.83.2/go.mod h1:YPI1hK3kDked6iHvgX3tR0y+nX/qpMFKhPgFsokw1S8= google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= diff --git a/backend/internal/service/seedance.go b/backend/internal/service/seedance.go index faa83da49e..36d9b0ca06 100644 --- a/backend/internal/service/seedance.go +++ b/backend/internal/service/seedance.go @@ -74,7 +74,7 @@ func buildSeedanceURL(base string, endpoint GrokMediaEndpoint, taskID string) (s // 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") + 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 { @@ -86,7 +86,8 @@ func (s *OpenAIGatewayService) ForwardSeedance(ctx context.Context, c *gin.Conte } model, upstreamModel := "", "" method := http.MethodGet - if endpoint == SeedanceEndpointCreate { + switch endpoint { + case SeedanceEndpointCreate: info, parseErr := ParseSeedanceRequest(body) if parseErr != nil { return nil, parseErr @@ -98,12 +99,12 @@ func (s *OpenAIGatewayService) ForwardSeedance(ctx context.Context, c *gin.Conte return nil, err } method = http.MethodPost - } else if endpoint == SeedanceEndpointDelete { + case SeedanceEndpointDelete: method = http.MethodDelete } token := strings.TrimSpace(account.GetCredential("api_key")) if token == "" { - return nil, fmt.Errorf("Seedance account missing api_key") + return nil, fmt.Errorf("seedance account missing api_key") } started := time.Now() req, err := http.NewRequestWithContext(ctx, method, target, bytes.NewReader(body)) @@ -123,7 +124,7 @@ func (s *OpenAIGatewayService) ForwardSeedance(ctx context.Context, c *gin.Conte if err != nil { return nil, err } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() responseBody, err := ReadUpstreamResponseBody(resp.Body, s.cfg, c, openAITooLargeError) if err != nil { return nil, err @@ -132,13 +133,13 @@ func (s *OpenAIGatewayService) ForwardSeedance(ctx context.Context, c *gin.Conte // 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) + 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") + return nil, fmt.Errorf("seedance create response missing task ID") } result.ResponseID = SeedanceTaskKey(id) }