mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-14 03:52:26 +08:00
feat: support native Seedance Ark video task API
This commit is contained in:
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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"}`},
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)。
|
||||
@@ -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<string, unknown> | null => {
|
||||
|
||||
if (applyOpenAIEndpointCapabilities) {
|
||||
credentials.openai_capabilities =
|
||||
openAIEndpointCapabilities.value.length === 2
|
||||
openAIEndpointCapabilities.value.length === 2 && !openAIEndpointCapabilities.value.includes('seedance')
|
||||
? null
|
||||
: [...openAIEndpointCapabilities.value]
|
||||
credentialsChanged = true
|
||||
|
||||
@@ -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<string, unknown>) => {
|
||||
const capabilities = normalizeOpenAIEndpointCapabilities(openAIEndpointCapabilities.value)
|
||||
if (capabilities.length === 2) {
|
||||
if (capabilities.length === 2 && !capabilities.includes('seedance')) {
|
||||
delete credentials.openai_capabilities
|
||||
return
|
||||
}
|
||||
|
||||
@@ -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<string, unknown>): OpenAIEndpointCapability[] => {
|
||||
@@ -3700,7 +3701,7 @@ const readOpenAIEndpointCapabilities = (credentials?: Record<string, unknown>):
|
||||
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<string, unknown>) => {
|
||||
const capabilities = normalizeOpenAIEndpointCapabilities(openAIEndpointCapabilities.value)
|
||||
if (capabilities.length === 2) {
|
||||
if (capabilities.length === 2 && !capabilities.includes('seedance')) {
|
||||
delete credentials.openai_capabilities
|
||||
return
|
||||
}
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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<HTMLInputElement>('[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()
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user