Merge pull request #7247 from Qiaogun/codex/seedance-native-api

feat: 支持 Seedance / Ark 原生视频任务 API
This commit is contained in:
Wesley Liddick authored and GitHub committed 2026-09-19 12:11:17 +08:00
commit aea725f2ea
19 files changed
+632 -32

No files matched your search

+5
View File
@@ -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
}
+11 -2
View File
@@ -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),
+155
View File
@@ -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
}
+112
View File
@@ -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)
}