mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-14 03:52:26 +08:00
Merge pull request #7247 from Qiaogun/codex/seedance-native-api
feat: 支持 Seedance / Ark 原生视频任务 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,155 @@
|
||||
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
|
||||
switch endpoint {
|
||||
case 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
|
||||
case 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 func() { _ = 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)
|
||||
}
|
||||
Reference in New Issue
Block a user