feat(api): 火山模型协议 - 接入方舟与即梦图片视频生成并支持 AK/SK 凭证

This commit is contained in:
ddCat committed 2026-07-29 18:08:01 +08:00
1 parent 9ad2fb1b99
commit c33e05ff63
29 files changed
+1739 -310

No files matched your search

+25 -28
View File
@@ -302,7 +302,7 @@ func RegisterAdminRoutes(r *gin.RouterGroup, svc *service.Service) {
}
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
channels, err := svc.AdminSystemChannelPage(user, service.AdminListQuery{Keyword: c.Query("keyword"), Type: c.Query("interfaceType"), Status: c.Query("status"), Page: page, Limit: limit})
channels, err := svc.AdminSystemChannelPage(user, service.AdminListQuery{Keyword: c.Query("keyword"), Status: c.Query("status"), Page: page, Limit: limit})
if err != nil {
failService(c, err)
return
@@ -629,11 +629,22 @@ func proxySystemRequest(c *gin.Context, svc *service.Service, user *model.User,
fail(c, http.StatusBadRequest, err)
return
}
if err := authorizeSystemProxy(channel, c.Request.Method, path, c.GetHeader("Content-Type"), body); err != nil {
modelName := proxyRequestModelForPath(path, c.GetHeader("Content-Type"), body)
protocol := model.ChannelInterfaceType("")
capability := "text"
if !(c.Request.Method == http.MethodGet && path == "/models") {
channelModel, modelErr := svc.SystemChannelModel(channel.ID, modelName)
if modelErr != nil || channelModel.Protocol == "" {
fail(c, http.StatusForbidden, errors.New("当前系统渠道未授权该模型或模型协议尚未配置"))
return
}
protocol = channelModel.Protocol
capability = channelModel.Capability
}
if err := authorizeSystemProxy(channel, protocol, c.Request.Method, path, c.GetHeader("Content-Type"), body); err != nil {
fail(c, http.StatusForbidden, err)
return
}
modelName := proxyRequestModel(c.GetHeader("Content-Type"), body)
billingOrderID := ""
query := c.Request.URL.Query()
for _, key := range []string{"key", "api_key", "access_token", "token"} {
@@ -651,7 +662,7 @@ func proxySystemRequest(c *gin.Context, svc *service.Service, user *model.User,
// 同步代理与后台任务必须共享渠道槽位,否则两条入口会共同超过供应商并发上限。
releaseChannel, concurrencyLimit, err := svc.AcquireChannelSlot(c.Request.Context(), channel.ID, "", 36*time.Minute)
if err != nil {
log := apiCallLog(user, channel, billingOrderID, c.Request.Method, path, target, body, c.GetHeader("Content-Type"), model.ApiCallStatusFailed, 0, time.Since(startedAt), err.Error(), concurrencyLimit)
log := apiCallLog(user, channel, billingOrderID, capability, protocol, c.Request.Method, path, target, body, c.GetHeader("Content-Type"), model.ApiCallStatusFailed, 0, time.Since(startedAt), err.Error(), concurrencyLimit)
log.ErrorCode, log.Error = service.ChannelSlotFailureDetails(err)
logSystemProxyCall(svc, log, nil)
fail(c, http.StatusServiceUnavailable, err)
@@ -659,15 +670,6 @@ func proxySystemRequest(c *gin.Context, svc *service.Service, user *model.User,
}
defer releaseChannel()
if c.Request.Method == http.MethodPost {
capability := "text"
switch channel.InterfaceType {
case model.ChannelInterfaceOpenAIImage:
capability = "image"
case model.ChannelInterfaceOpenAIAudio:
capability = "audio"
case model.ChannelInterfaceNewAPIVideo, model.ChannelInterfaceNewAPIChannel1, model.ChannelInterfaceNewAPIChannel2, model.ChannelInterfaceXAIVideo, model.ChannelInterfaceGeminiVeo:
capability = "video"
}
order, err := svc.ReserveProxyBilling(user.ID, channel.ID, strings.TrimPrefix(modelName, "models/"), capability, c.GetHeader("X-Canvas-Scene"), c.GetHeader("X-Idempotency-Key"), proxyRequestVideoSeconds(c.GetHeader("Content-Type"), body))
if err != nil {
failService(c, err)
@@ -692,7 +694,7 @@ func proxySystemRequest(c *gin.Context, svc *service.Service, user *model.User,
if accept := c.GetHeader("Accept"); accept != "" {
upstreamReq.Header.Set("Accept", accept)
}
if channel.APIFormat == "gemini" {
if protocol == model.ChannelInterfaceGeminiVeo {
upstreamReq.Header.Set("x-goog-api-key", channel.APIKey)
} else {
upstreamReq.Header.Set("Authorization", "Bearer "+channel.APIKey)
@@ -706,7 +708,7 @@ func proxySystemRequest(c *gin.Context, svc *service.Service, user *model.User,
status = model.ApiCallStatusFailed
errorText = err.Error()
_ = svc.MarkBillingUncertain(billingOrderID, "系统渠道连接中断,费用状态待核对")
logSystemProxyCall(svc, apiCallLog(user, channel, billingOrderID, c.Request.Method, path, target, body, c.GetHeader("Content-Type"), status, statusCode, time.Since(startedAt), errorText, concurrencyLimit), nil)
logSystemProxyCall(svc, apiCallLog(user, channel, billingOrderID, capability, protocol, c.Request.Method, path, target, body, c.GetHeader("Content-Type"), status, statusCode, time.Since(startedAt), errorText, concurrencyLimit), nil)
fail(c, http.StatusBadGateway, errors.New("系统渠道连接失败"))
return
}
@@ -721,7 +723,7 @@ func proxySystemRequest(c *gin.Context, svc *service.Service, user *model.User,
status = model.ApiCallStatusFailed
errorText = readErr.Error()
_ = svc.MarkBillingUncertain(billingOrderID, "系统渠道响应读取失败,费用状态待核对")
logSystemProxyCall(svc, apiCallLog(user, channel, billingOrderID, c.Request.Method, path, target, body, c.GetHeader("Content-Type"), status, statusCode, time.Since(startedAt), errorText, concurrencyLimit), nil)
logSystemProxyCall(svc, apiCallLog(user, channel, billingOrderID, capability, protocol, c.Request.Method, path, target, body, c.GetHeader("Content-Type"), status, statusCode, time.Since(startedAt), errorText, concurrencyLimit), nil)
fail(c, http.StatusBadGateway, errors.New("系统渠道响应读取失败"))
return
}
@@ -739,7 +741,7 @@ func proxySystemRequest(c *gin.Context, svc *service.Service, user *model.User,
} else {
_ = svc.RefundBilling(billingOrderID, "上游明确返回失败")
}
logSystemProxyCall(svc, apiCallLog(user, channel, billingOrderID, c.Request.Method, path, target, body, c.GetHeader("Content-Type"), status, statusCode, time.Since(startedAt), errorText, concurrencyLimit), responseBody)
logSystemProxyCall(svc, apiCallLog(user, channel, billingOrderID, capability, protocol, c.Request.Method, path, target, body, c.GetHeader("Content-Type"), status, statusCode, time.Since(startedAt), errorText, concurrencyLimit), responseBody)
for _, key := range []string{"Content-Type", "Cache-Control", "Content-Disposition"} {
if value := resp.Header.Get(key); value != "" {
c.Header(key, value)
@@ -749,17 +751,12 @@ func proxySystemRequest(c *gin.Context, svc *service.Service, user *model.User,
c.Data(resp.StatusCode, resp.Header.Get("Content-Type"), responseBody)
}
func apiCallLog(user *model.User, channel *model.ModelChannel, billingOrderID string, method string, path string, target string, body []byte, contentType string, status model.ApiCallStatus, statusCode int, duration time.Duration, errorText string, concurrencyLimit int) model.ApiCallLog {
capability := "text"
switch channel.InterfaceType {
case model.ChannelInterfaceOpenAIImage:
capability = "image"
case model.ChannelInterfaceOpenAIAudio:
capability = "audio"
case model.ChannelInterfaceNewAPIVideo, model.ChannelInterfaceNewAPIChannel1, model.ChannelInterfaceNewAPIChannel2, model.ChannelInterfaceXAIVideo, model.ChannelInterfaceGeminiVeo:
capability = "video"
}
func apiCallLog(user *model.User, channel *model.ModelChannel, billingOrderID string, capability string, protocol model.ChannelInterfaceType, method string, path string, target string, body []byte, contentType string, status model.ApiCallStatus, statusCode int, duration time.Duration, errorText string, concurrencyLimit int) model.ApiCallLog {
requestKind := "create"
apiFormat := "openai"
if protocol == model.ChannelInterfaceGeminiVeo {
apiFormat = "gemini"
}
if method == http.MethodGet {
requestKind = "poll"
if strings.HasSuffix(strings.TrimRight(path, "/"), "/content") {
@@ -774,7 +771,7 @@ func apiCallLog(user *model.User, channel *model.ModelChannel, billingOrderID st
Capability: capability,
RequestKind: requestKind,
Billable: method == http.MethodPost,
APIFormat: channel.APIFormat,
APIFormat: apiFormat,
Method: method,
Path: path,
Model: readPayloadModel(body),
+19 -6
View File
@@ -28,6 +28,7 @@ var (
customGeminiRelayPath = regexp.MustCompile(`(?:^|/)models/[^/:]+:(generateContent|streamGenerateContent|predictLongRunning)$`)
customVideoTaskPath = regexp.MustCompile(`(?:^|/)video/generations/[^/]+$`)
customXAIVideoTaskPath = regexp.MustCompile(`(?:^|/)videos/[^/]+$`)
customArkVideoTaskPath = regexp.MustCompile(`(?:^|/)contents/generations/tasks/[^/]+$`)
customGeminiOperationPath = regexp.MustCompile(`(?:^|/)(?:models/[^/]+/)?operations/[^/]+$`)
openAIPostEndpoints = map[string]bool{
"/responses": true, "/chat/completions": true, "/images/generations": true, "/images/edits": true,
@@ -62,7 +63,7 @@ func authorizeCustomRelay(method string, target *url.URL, apiFormat string, cont
if method == http.MethodGet {
allowed := requestPath == "/models" || strings.HasSuffix(requestPath, "/models")
if apiFormat == "openai" {
allowed = allowed || customVideoTaskPath.MatchString(requestPath) || customXAIVideoTaskPath.MatchString(requestPath)
allowed = allowed || customVideoTaskPath.MatchString(requestPath) || customXAIVideoTaskPath.MatchString(requestPath) || customArkVideoTaskPath.MatchString(requestPath)
} else {
allowed = allowed || customGeminiOperationPath.MatchString(requestPath)
}
@@ -79,7 +80,7 @@ func authorizeCustomRelay(method string, target *url.URL, apiFormat string, cont
return errors.New("自定义渠道不允许使用该请求方法")
}
if apiFormat == "openai" {
if len(query) != 0 || (!strings.HasSuffix(requestPath, "/responses") && !strings.HasSuffix(requestPath, "/chat/completions") && !strings.HasSuffix(requestPath, "/video/generations") && !strings.HasSuffix(requestPath, "/videos/generations")) {
if len(query) != 0 || (!strings.HasSuffix(requestPath, "/responses") && !strings.HasSuffix(requestPath, "/chat/completions") && !strings.HasSuffix(requestPath, "/images/generations") && !strings.HasSuffix(requestPath, "/video/generations") && !strings.HasSuffix(requestPath, "/videos/generations") && !strings.HasSuffix(requestPath, "/contents/generations/tasks")) {
return errors.New("自定义渠道不允许访问该上游接口")
}
return nil
@@ -138,7 +139,7 @@ func loadRuntimePolicy(c *gin.Context, svc *service.Service) (service.RuntimePol
return policy, true
}
func authorizeSystemProxy(channel *model.ModelChannel, method string, requestPath string, contentType string, body []byte) error {
func authorizeSystemProxy(channel *model.ModelChannel, protocol model.ChannelInterfaceType, method string, requestPath string, contentType string, body []byte) error {
requestPath, err := normalizedProxyPath(requestPath)
if err != nil {
return err
@@ -146,7 +147,7 @@ func authorizeSystemProxy(channel *model.ModelChannel, method string, requestPat
if method == http.MethodGet && requestPath == "/models" {
return nil
}
if channel.APIFormat == "gemini" {
if protocol == model.ChannelInterfaceGeminiVeo {
matches := geminiGeneratePath.FindStringSubmatch(requestPath)
if method != http.MethodPost || len(matches) != 3 {
return errors.New("系统渠道不允许访问该上游接口")
@@ -160,7 +161,7 @@ func authorizeSystemProxy(channel *model.ModelChannel, method string, requestPat
if method != http.MethodPost || !openAIPostEndpoints[requestPath] {
return errors.New("系统渠道不允许访问该上游接口")
}
if channel.InterfaceType != "" && !interfaceAllowsProxyPath(channel.InterfaceType, requestPath) {
if protocol != "" && !interfaceAllowsProxyPath(protocol, requestPath) {
return errors.New("当前接口类型不允许访问该上游接口")
}
modelName := proxyRequestModel(contentType, body)
@@ -178,9 +179,11 @@ func interfaceAllowsProxyPath(interfaceType model.ChannelInterfaceType, requestP
return requestPath == "/responses"
case model.ChannelInterfaceOpenAIImage:
return requestPath == "/images/generations" || requestPath == "/images/edits"
case model.ChannelInterfaceVolcengineArkImage:
return requestPath == "/images/generations"
case model.ChannelInterfaceOpenAIAudio:
return requestPath == "/audio/speech"
case model.ChannelInterfaceNewAPIVideo, model.ChannelInterfaceNewAPIChannel1, model.ChannelInterfaceNewAPIChannel2, model.ChannelInterfaceXAIVideo, model.ChannelInterfaceGeminiVeo:
case model.ChannelInterfaceNewAPIVideo, model.ChannelInterfaceNewAPIChannel1, model.ChannelInterfaceNewAPIChannel2, model.ChannelInterfaceXAIVideo, model.ChannelInterfaceVolcengineArkVideo, model.ChannelInterfaceVolcengineJiMengImage, model.ChannelInterfaceVolcengineJiMengVideo, model.ChannelInterfaceGeminiVeo:
return false
default:
return true
@@ -235,6 +238,16 @@ func proxyRequestModel(contentType string, body []byte) string {
return strings.TrimSpace(modelName)
}
func proxyRequestModelForPath(requestPath string, contentType string, body []byte) string {
if matches := geminiGeneratePath.FindStringSubmatch(requestPath); len(matches) == 3 {
modelName, err := url.PathUnescape(matches[1])
if err == nil {
return strings.TrimPrefix(strings.TrimSpace(modelName), "models/")
}
}
return proxyRequestModel(contentType, body)
}
func proxyRequestVideoSeconds(contentType string, body []byte) int64 {
mediaType, params, _ := mime.ParseMediaType(contentType)
if strings.HasPrefix(mediaType, "multipart/") {
+24 -10
View File
@@ -13,7 +13,7 @@ import (
func TestAuthorizeSystemProxyAllowsConfiguredGenerationModel(t *testing.T) {
channel := &model.ModelChannel{APIFormat: "openai", ModelsJSON: `["gpt-image-1"]`}
body := []byte(`{"model":"gpt-image-1","prompt":"test"}`)
if err := authorizeSystemProxy(channel, http.MethodPost, "/images/generations", "application/json", body); err != nil {
if err := authorizeSystemProxy(channel, model.ChannelInterfaceOpenAIImage, http.MethodPost, "/images/generations", "application/json", body); err != nil {
t.Fatalf("authorizeSystemProxy() error = %v", err)
}
}
@@ -32,6 +32,9 @@ func TestAuthorizeCustomRelayAllowsModelsAndAgentEndpoints(t *testing.T) {
{method: http.MethodGet, target: "https://api.example.com/v1/video/generations/task-1", apiFormat: "openai"},
{method: http.MethodPost, target: "https://api.x.ai/v1/videos/generations", apiFormat: "openai", contentType: "application/json"},
{method: http.MethodGet, target: "https://api.x.ai/v1/videos/request-1", apiFormat: "openai"},
{method: http.MethodPost, target: "https://ark.cn-beijing.volces.com/api/v3/images/generations", apiFormat: "openai", contentType: "application/json"},
{method: http.MethodPost, target: "https://ark.cn-beijing.volces.com/api/v3/contents/generations/tasks", apiFormat: "openai", contentType: "application/json"},
{method: http.MethodGet, target: "https://ark.cn-beijing.volces.com/api/v3/contents/generations/tasks/task-1", apiFormat: "openai"},
{method: http.MethodPost, target: "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-pro:streamGenerateContent?alt=sse", apiFormat: "gemini", contentType: "application/json"},
{method: http.MethodPost, target: "https://generativelanguage.googleapis.com/v1beta/models/veo-3.0-generate-preview:predictLongRunning", apiFormat: "gemini", contentType: "application/json"},
{method: http.MethodGet, target: "https://generativelanguage.googleapis.com/v1beta/operations/operation-1", apiFormat: "gemini"},
@@ -74,10 +77,10 @@ func TestAuthorizeCustomRelayRejectsArbitraryRequestsAndCredentialQueries(t *tes
func TestAuthorizeSystemProxyRejectsArbitraryPathAndModel(t *testing.T) {
channel := &model.ModelChannel{APIFormat: "openai", ModelsJSON: `["gpt-image-1"]`}
if err := authorizeSystemProxy(channel, http.MethodDelete, "/account", "application/json", nil); err == nil {
if err := authorizeSystemProxy(channel, model.ChannelInterfaceOpenAIImage, http.MethodDelete, "/account", "application/json", nil); err == nil {
t.Fatal("expected arbitrary path to be rejected")
}
if err := authorizeSystemProxy(channel, http.MethodPost, "/images/generations", "application/json", []byte(`{"model":"unapproved"}`)); err == nil {
if err := authorizeSystemProxy(channel, model.ChannelInterfaceOpenAIImage, http.MethodPost, "/images/generations", "application/json", []byte(`{"model":"unapproved"}`)); err == nil {
t.Fatal("expected unapproved model to be rejected")
}
}
@@ -92,22 +95,33 @@ func TestProxyRequestModelReadsMultipartField(t *testing.T) {
}
}
func TestAuthorizeSystemProxyRestrictsConfiguredInterfaceType(t *testing.T) {
func TestAuthorizeSystemProxyRestrictsModelProtocol(t *testing.T) {
body := []byte(`{"model":"gpt-4.1"}`)
channel := &model.ModelChannel{APIFormat: "openai", InterfaceType: model.ChannelInterfaceChatCompletion, ModelsJSON: `["gpt-4.1"]`}
if err := authorizeSystemProxy(channel, http.MethodPost, "/chat/completions", "application/json", body); err != nil {
channel := &model.ModelChannel{APIFormat: "openai", ModelsJSON: `["gpt-4.1"]`}
if err := authorizeSystemProxy(channel, model.ChannelInterfaceChatCompletion, http.MethodPost, "/chat/completions", "application/json", body); err != nil {
t.Fatalf("authorizeSystemProxy() error = %v", err)
}
if err := authorizeSystemProxy(channel, http.MethodPost, "/responses", "application/json", body); err == nil {
if err := authorizeSystemProxy(channel, model.ChannelInterfaceChatCompletion, http.MethodPost, "/responses", "application/json", body); err == nil {
t.Fatal("authorizeSystemProxy() error = nil for mismatched interface")
}
}
func TestAuthorizeSystemProxyVolcengineArkImageOnlyAllowsGenerations(t *testing.T) {
body := []byte(`{"model":"doubao-seedream-test"}`)
channel := &model.ModelChannel{APIFormat: "openai", ModelsJSON: `["doubao-seedream-test"]`}
if err := authorizeSystemProxy(channel, model.ChannelInterfaceVolcengineArkImage, http.MethodPost, "/images/generations", "application/json", body); err != nil {
t.Fatalf("authorizeSystemProxy() error = %v", err)
}
if err := authorizeSystemProxy(channel, model.ChannelInterfaceVolcengineArkImage, http.MethodPost, "/images/edits", "application/json", body); err == nil {
t.Fatal("authorizeSystemProxy() error = nil for unsupported Ark image edits path")
}
}
func TestAuthorizeSystemProxyBlocksBackendOnlyVideoInterfaces(t *testing.T) {
body := []byte(`{"model":"grok-image-video"}`)
for _, interfaceType := range []model.ChannelInterfaceType{model.ChannelInterfaceNewAPIChannel2, model.ChannelInterfaceXAIVideo} {
channel := &model.ModelChannel{APIFormat: "openai", InterfaceType: interfaceType, ModelsJSON: `["grok-image-video"]`}
if err := authorizeSystemProxy(channel, http.MethodPost, "/video/generations", "application/json", body); err == nil {
for _, interfaceType := range []model.ChannelInterfaceType{model.ChannelInterfaceNewAPIChannel2, model.ChannelInterfaceXAIVideo, model.ChannelInterfaceVolcengineJiMengImage, model.ChannelInterfaceVolcengineJiMengVideo} {
channel := &model.ModelChannel{APIFormat: "openai", ModelsJSON: `["grok-image-video"]`}
if err := authorizeSystemProxy(channel, interfaceType, http.MethodPost, "/video/generations", "application/json", body); err == nil {
t.Fatalf("authorizeSystemProxy() error = nil for backend-only interface %q", interfaceType)
}
}
+33 -29
View File
@@ -59,15 +59,19 @@ const (
ChannelScopeSystem ChannelScope = "system"
ChannelScopeUser ChannelScope = "user"
ChannelInterfaceChatCompletion ChannelInterfaceType = "chat-completion"
ChannelInterfaceOpenAIResponse ChannelInterfaceType = "openai-response"
ChannelInterfaceOpenAIImage ChannelInterfaceType = "openai-image"
ChannelInterfaceOpenAIAudio ChannelInterfaceType = "openai-audio"
ChannelInterfaceNewAPIVideo ChannelInterfaceType = "newapi"
ChannelInterfaceNewAPIChannel1 ChannelInterfaceType = "newapi-channel-1"
ChannelInterfaceNewAPIChannel2 ChannelInterfaceType = "newapi-channel-2"
ChannelInterfaceXAIVideo ChannelInterfaceType = "xai-video"
ChannelInterfaceGeminiVeo ChannelInterfaceType = "gemini-veo"
ChannelInterfaceChatCompletion ChannelInterfaceType = "chat-completion"
ChannelInterfaceOpenAIResponse ChannelInterfaceType = "openai-response"
ChannelInterfaceOpenAIImage ChannelInterfaceType = "openai-image"
ChannelInterfaceVolcengineArkImage ChannelInterfaceType = "volcengine-ark-image"
ChannelInterfaceVolcengineJiMengImage ChannelInterfaceType = "volcengine-jimeng-image"
ChannelInterfaceOpenAIAudio ChannelInterfaceType = "openai-audio"
ChannelInterfaceNewAPIVideo ChannelInterfaceType = "newapi"
ChannelInterfaceNewAPIChannel1 ChannelInterfaceType = "newapi-channel-1"
ChannelInterfaceNewAPIChannel2 ChannelInterfaceType = "newapi-channel-2"
ChannelInterfaceXAIVideo ChannelInterfaceType = "xai-video"
ChannelInterfaceVolcengineArkVideo ChannelInterfaceType = "volcengine-ark-video"
ChannelInterfaceVolcengineJiMengVideo ChannelInterfaceType = "volcengine-jimeng-video"
ChannelInterfaceGeminiVeo ChannelInterfaceType = "gemini-veo"
ApiCallStatusSucceeded ApiCallStatus = "succeeded"
ApiCallStatusFailed ApiCallStatus = "failed"
@@ -195,20 +199,20 @@ type EmailVerificationCode struct {
}
type ModelChannel struct {
ID string `json:"id" gorm:"primaryKey;size:36"`
UserID string `json:"userId" gorm:"index;size:36"`
Scope ChannelScope `json:"scope" gorm:"index;size:24"`
Enabled bool `json:"enabled" gorm:"index"`
Name string `json:"name" gorm:"size:80"`
BaseURL string `json:"baseUrl"`
APIKey string `json:"-"`
APIFormat string `json:"apiFormat" gorm:"size:24"`
InterfaceType ChannelInterfaceType `json:"interfaceType" gorm:"size:32"`
ConcurrencyLimit int `json:"concurrencyLimit"`
ModelsJSON string `json:"modelsJson" gorm:"type:text"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
ID string `json:"id" gorm:"primaryKey;size:36"`
UserID string `json:"userId" gorm:"index;size:36"`
Scope ChannelScope `json:"scope" gorm:"index;size:24"`
Enabled bool `json:"enabled" gorm:"index"`
Name string `json:"name" gorm:"size:80"`
BaseURL string `json:"baseUrl"`
APIKey string `json:"-"`
SecretKey string `json:"-"`
APIFormat string `json:"apiFormat" gorm:"size:24"`
ConcurrencyLimit int `json:"concurrencyLimit"`
ModelsJSON string `json:"modelsJson" gorm:"type:text"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
}
type ChannelModel struct {
@@ -526,12 +530,12 @@ type VoiceProfile struct {
}
type CharacterVoiceBinding struct {
ID string `json:"id" gorm:"primaryKey;size:36"`
AssetVersionID string `json:"assetVersionId" gorm:"uniqueIndex;size:36"`
VoiceProfileID string `json:"voiceProfileId" gorm:"index;size:36"`
Instructions string `json:"instructions" gorm:"type:text"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
ID string `json:"id" gorm:"primaryKey;size:36"`
AssetVersionID string `json:"assetVersionId" gorm:"uniqueIndex;size:36"`
VoiceProfileID string `json:"voiceProfileId" gorm:"index;size:36"`
Instructions string `json:"instructions" gorm:"type:text"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
// Project 是短剧领域聚合根;CanvasProject 仍代表可独立创作的画布文档。
+2 -5
View File
@@ -467,7 +467,7 @@ func (r *Repository) HistoricalSystemChannelReferences() ([]model.ModelChannel,
return channels, err
}
func (r *Repository) AdminSystemChannels(keyword string, interfaceType string, status string, limit int, offset int) ([]model.ModelChannel, int64, error) {
func (r *Repository) AdminSystemChannels(keyword string, status string, limit int, offset int) ([]model.ModelChannel, int64, error) {
var channels []model.ModelChannel
var total int64
query := r.db.Model(&model.ModelChannel{}).Where("scope = ?", model.ChannelScopeSystem)
@@ -475,9 +475,6 @@ func (r *Repository) AdminSystemChannels(keyword string, interfaceType string, s
pattern := "%" + strings.ToLower(value) + "%"
query = query.Where("lower(name) LIKE ? OR lower(base_url) LIKE ?", pattern, pattern)
}
if value := strings.TrimSpace(interfaceType); value != "" && value != "all" {
query = query.Where("interface_type = ?", value)
}
if status == "enabled" {
query = query.Where("enabled = ?", true)
} else if status == "disabled" {
@@ -519,7 +516,7 @@ func (r *Repository) DeleteSystemChannel(id string) error {
return r.db.Transaction(func(tx *gorm.DB) error {
channelResult := tx.Model(&model.ModelChannel{}).
Where("id = ? AND scope = ?", id, model.ChannelScopeSystem).
Updates(map[string]any{"api_key": "", "enabled": false, "updated_at": now})
Updates(map[string]any{"api_key": "", "secret_key": "", "enabled": false, "updated_at": now})
if channelResult.Error != nil {
return channelResult.Error
}
+27 -59
View File
@@ -78,7 +78,7 @@ type ChannelRequest struct {
Name string `json:"name"`
BaseURL string `json:"baseUrl"`
APIKey string `json:"apiKey"`
InterfaceType string `json:"interfaceType"`
SecretKey string `json:"secretKey"`
ConcurrencyLimit *int `json:"concurrencyLimit"`
UseGlobalConcurrency *bool `json:"useGlobalConcurrency"`
Models []string `json:"models"`
@@ -86,21 +86,21 @@ type ChannelRequest struct {
}
type PublicModelChannel struct {
ID string `json:"id"`
UserID string `json:"userId"`
Scope model.ChannelScope `json:"scope"`
Enabled bool `json:"enabled"`
Name string `json:"name"`
BaseURL string `json:"baseUrl"`
APIKey string `json:"apiKey"`
APIFormat string `json:"apiFormat"`
InterfaceType model.ChannelInterfaceType `json:"interfaceType"`
ConcurrencyLimit int `json:"concurrencyLimit"`
Models []string `json:"models"`
ModelCosts []PublicChannelModelPrice `json:"modelCosts"`
HasAPIKey bool `json:"hasApiKey"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
ID string `json:"id"`
UserID string `json:"userId"`
Scope model.ChannelScope `json:"scope"`
Enabled bool `json:"enabled"`
Name string `json:"name"`
BaseURL string `json:"baseUrl"`
APIKey string `json:"apiKey"`
APIFormat string `json:"apiFormat"`
ConcurrencyLimit int `json:"concurrencyLimit"`
Models []string `json:"models"`
ModelCosts []PublicChannelModelPrice `json:"modelCosts"`
HasAPIKey bool `json:"hasApiKey"`
HasSecretKey bool `json:"hasSecretKey"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
type PublicChannelModelPrice struct {
@@ -366,7 +366,7 @@ func (s *Service) AdminSystemChannelPage(actor *model.User, query AdminListQuery
return nil, err
}
page, limit := normalizeAdminPage(query.Page, query.Limit)
channels, total, err := s.repo.AdminSystemChannels(query.Keyword, query.Type, query.Status, limit, (page-1)*limit)
channels, total, err := s.repo.AdminSystemChannels(query.Keyword, query.Status, limit, (page-1)*limit)
if err != nil {
return nil, err
}
@@ -433,6 +433,9 @@ func (s *Service) UpdateSystemChannel(actor *model.User, id string, req ChannelR
if req.APIKey == "" {
next.APIKey = channel.APIKey
}
if req.SecretKey == "" {
next.SecretKey = channel.SecretKey
}
if err := s.repo.Save(&next); err != nil {
return nil, err
}
@@ -581,16 +584,12 @@ func (s *Service) APICallLogs(actor *model.User, limit int) ([]model.ApiCallLog,
func channelFromRequest(req ChannelRequest, channel model.ModelChannel) (model.ModelChannel, error) {
name := strings.TrimSpace(req.Name)
baseURL := strings.TrimSpace(req.BaseURL)
interfaceType := model.ChannelInterfaceType(strings.TrimSpace(req.InterfaceType))
if name == "" {
return channel, BadAuthRequest("请填写渠道名称")
}
if baseURL == "" {
return channel, BadAuthRequest("请填写 Base URL")
}
if !validChannelInterfaceType(interfaceType) {
return channel, BadAuthRequest("请选择有效的接口类型")
}
if _, err := ValidateOutboundURL(baseURL); err != nil {
return channel, err
}
@@ -601,12 +600,11 @@ func channelFromRequest(req ChannelRequest, channel model.ModelChannel) (model.M
if req.APIKey != "" {
channel.APIKey = req.APIKey
}
// Gemini Veo 使用 x-goog-api-key,其余系统协议使用 Bearer 鉴权。
channel.APIFormat = "openai"
if interfaceType == model.ChannelInterfaceGeminiVeo {
channel.APIFormat = "gemini"
if req.SecretKey != "" {
channel.SecretKey = req.SecretKey
}
channel.InterfaceType = interfaceType
// 系统渠道只保存地址与凭证;实际协议和鉴权方式由所选模型决定。
channel.APIFormat = "openai"
if req.UseGlobalConcurrency != nil && *req.UseGlobalConcurrency {
channel.ConcurrencyLimit = 0
} else if req.ConcurrencyLimit != nil {
@@ -634,40 +632,18 @@ func mergeChannelRequest(req ChannelRequest, channel model.ModelChannel) Channel
if req.Models == nil {
req.Models = channelModelNames(channel)
}
if strings.TrimSpace(req.InterfaceType) == "" {
req.InterfaceType = string(channel.InterfaceType)
if req.InterfaceType == "" {
req.InterfaceType = string(inferChannelInterfaceType(req.Models))
}
}
return req
}
func validChannelInterfaceType(value model.ChannelInterfaceType) bool {
switch value {
case model.ChannelInterfaceChatCompletion, model.ChannelInterfaceOpenAIResponse, model.ChannelInterfaceOpenAIImage, model.ChannelInterfaceOpenAIAudio, model.ChannelInterfaceNewAPIVideo, model.ChannelInterfaceNewAPIChannel1, model.ChannelInterfaceNewAPIChannel2, model.ChannelInterfaceXAIVideo, model.ChannelInterfaceGeminiVeo:
case model.ChannelInterfaceChatCompletion, model.ChannelInterfaceOpenAIResponse, model.ChannelInterfaceOpenAIImage, model.ChannelInterfaceVolcengineArkImage, model.ChannelInterfaceVolcengineJiMengImage, model.ChannelInterfaceOpenAIAudio, model.ChannelInterfaceNewAPIVideo, model.ChannelInterfaceNewAPIChannel1, model.ChannelInterfaceNewAPIChannel2, model.ChannelInterfaceXAIVideo, model.ChannelInterfaceVolcengineArkVideo, model.ChannelInterfaceVolcengineJiMengVideo, model.ChannelInterfaceGeminiVeo:
return true
default:
return false
}
}
func inferChannelInterfaceType(models []string) model.ChannelInterfaceType {
for _, name := range models {
value := strings.ToLower(name)
if strings.Contains(value, "video") || strings.Contains(value, "seedance") || strings.Contains(value, "sora") || strings.Contains(value, "veo") || strings.Contains(value, "kling") || strings.Contains(value, "wan") || strings.Contains(value, "hailuo") {
return model.ChannelInterfaceNewAPIVideo
}
}
for _, name := range models {
value := strings.ToLower(name)
if strings.Contains(value, "image") || strings.Contains(value, "seedream") || strings.Contains(value, "dall-e") || strings.Contains(value, "flux") || strings.Contains(value, "imagen") {
return model.ChannelInterfaceOpenAIImage
}
}
return model.ChannelInterfaceChatCompletion
}
func publicChannel(channel model.ModelChannel, admin bool, channelModels []model.ChannelModel) PublicModelChannel {
models := make([]string, 0, len(channelModels))
modelCosts := make([]PublicChannelModelPrice, 0, len(channelModels))
@@ -677,11 +653,7 @@ func publicChannel(channel model.ModelChannel, admin bool, channelModels []model
}
models = append(models, item.ModelKey)
if item.Enabled && item.PriceConfigured {
protocol := item.Protocol
if protocol == "" {
protocol = channel.InterfaceType
}
modelCosts = append(modelCosts, PublicChannelModelPrice{Model: item.ModelKey, DisplayName: item.DisplayName, Capability: item.Capability, Protocol: protocol, BillingMode: item.BillingMode, UnitPriceMicrocredits: item.UnitPriceMicrocredits})
modelCosts = append(modelCosts, PublicChannelModelPrice{Model: item.ModelKey, DisplayName: item.DisplayName, Capability: item.Capability, Protocol: item.Protocol, BillingMode: item.BillingMode, UnitPriceMicrocredits: item.UnitPriceMicrocredits})
}
}
if len(models) == 0 {
@@ -697,10 +669,6 @@ func publicChannel(channel model.ModelChannel, admin bool, channelModels []model
} else if admin {
apiKey = channel.APIKey
}
interfaceType := channel.InterfaceType
if !validChannelInterfaceType(interfaceType) {
interfaceType = inferChannelInterfaceType(models)
}
return PublicModelChannel{
ID: channel.ID,
UserID: channel.UserID,
@@ -710,11 +678,11 @@ func publicChannel(channel model.ModelChannel, admin bool, channelModels []model
BaseURL: baseURL,
APIKey: apiKey,
APIFormat: channel.APIFormat,
InterfaceType: interfaceType,
ConcurrencyLimit: channel.ConcurrencyLimit,
Models: models,
ModelCosts: modelCosts,
HasAPIKey: strings.TrimSpace(channel.APIKey) != "",
HasSecretKey: strings.TrimSpace(channel.SecretKey) != "",
CreatedAt: channel.CreatedAt,
UpdatedAt: channel.UpdatedAt,
}
+9 -51
View File
@@ -8,65 +8,30 @@ import (
"infinite-canvas/backend/internal/model"
)
func TestChannelFromRequestStoresInterfaceType(t *testing.T) {
func TestChannelFromRequestStoresConnectionWithoutDefaultProtocol(t *testing.T) {
t.Setenv("CANVAS_ALLOW_PRIVATE_UPSTREAMS", "true")
server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
defer server.Close()
channel, err := channelFromRequest(ChannelRequest{
Name: "NewAPI 渠道 1",
Name: "混合模型渠道",
BaseURL: server.URL + "/v1",
APIKey: "secret",
InterfaceType: "newapi-channel-1",
APIKey: "access-key",
SecretKey: "secret-key",
ConcurrencyLimit: intPtr(6),
Models: []string{"seedance-2.0"},
}, model.ModelChannel{})
if err != nil {
t.Fatalf("channelFromRequest() error = %v", err)
}
if channel.InterfaceType != model.ChannelInterfaceNewAPIChannel1 {
t.Fatalf("InterfaceType = %q", channel.InterfaceType)
}
if channel.APIFormat != "openai" {
t.Fatalf("APIFormat = %q, want openai", channel.APIFormat)
}
if channel.ConcurrencyLimit != 6 {
t.Fatalf("ConcurrencyLimit = %d, want 6", channel.ConcurrencyLimit)
}
}
func TestChannelFromRequestStoresXAIVideoInterfaceType(t *testing.T) {
t.Setenv("CANVAS_ALLOW_PRIVATE_UPSTREAMS", "true")
server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
defer server.Close()
channel, err := channelFromRequest(ChannelRequest{
Name: "xAI Video",
BaseURL: server.URL + "/v1",
APIKey: "secret",
InterfaceType: "xai-video",
Models: []string{"grok-imagine-video-1.5"},
}, model.ModelChannel{})
if err != nil {
t.Fatalf("channelFromRequest() error = %v", err)
}
if channel.InterfaceType != model.ChannelInterfaceXAIVideo {
t.Fatalf("InterfaceType = %q", channel.InterfaceType)
}
}
func TestChannelFromRequestUsesGeminiAuthForVeo(t *testing.T) {
channel, err := channelFromRequest(ChannelRequest{
Name: "Gemini Veo",
BaseURL: "https://generativelanguage.googleapis.com",
APIKey: "key",
InterfaceType: "gemini-veo",
}, model.ModelChannel{})
if err != nil {
t.Fatalf("channelFromRequest() error = %v", err)
}
if channel.InterfaceType != model.ChannelInterfaceGeminiVeo || channel.APIFormat != "gemini" {
t.Fatalf("channel = %#v", channel)
if channel.APIKey != "access-key" || channel.SecretKey != "secret-key" {
t.Fatal("channel credentials were not stored")
}
}
@@ -78,21 +43,14 @@ func TestMergeChannelRequestSupportsEnabledOnlyPatch(t *testing.T) {
APIFormat: "openai",
ModelsJSON: `["custom-video"]`,
})
if req.Name != "Video" || req.BaseURL != "https://example.com/v1" || req.InterfaceType != "newapi" || len(req.Models) != 1 {
if req.Name != "Video" || req.BaseURL != "https://example.com/v1" || len(req.Models) != 1 {
t.Fatalf("mergeChannelRequest() = %#v", req)
}
}
func TestChannelFromRequestRejectsUnknownInterfaceType(t *testing.T) {
_, err := channelFromRequest(ChannelRequest{Name: "Bad", BaseURL: "https://example.com/v1", InterfaceType: "unknown"}, model.ModelChannel{})
if err == nil {
t.Fatal("channelFromRequest() error = nil")
}
}
func TestChannelFromRequestRejectsInvalidConcurrencyLimit(t *testing.T) {
for _, limit := range []int{0, 1000} {
_, err := channelFromRequest(ChannelRequest{Name: "Bad", BaseURL: "https://example.com/v1", InterfaceType: "newapi", ConcurrencyLimit: &limit}, model.ModelChannel{})
_, err := channelFromRequest(ChannelRequest{Name: "Bad", BaseURL: "https://example.com/v1", ConcurrencyLimit: &limit}, model.ModelChannel{})
if err == nil {
t.Fatalf("channelFromRequest() concurrencyLimit = %d, error = nil", limit)
}
@@ -108,7 +66,7 @@ func TestRuntimeConcurrencyUsesEnvironmentFallback(t *testing.T) {
}
useGlobal := true
channel, err := channelFromRequest(ChannelRequest{Name: "Global", BaseURL: "https://example.com/v1", InterfaceType: "newapi", UseGlobalConcurrency: &useGlobal}, model.ModelChannel{ConcurrencyLimit: 4})
channel, err := channelFromRequest(ChannelRequest{Name: "Global", BaseURL: "https://example.com/v1", UseGlobalConcurrency: &useGlobal}, model.ModelChannel{ConcurrencyLimit: 4})
if err != nil || channel.ConcurrencyLimit != 0 {
t.Fatalf("global concurrency channel = %#v, error = %v", channel, err)
}
+11 -14
View File
@@ -58,6 +58,10 @@ func (s *Service) AdminChannelModels(actor *model.User, channelID string) ([]mod
return s.ensureChannelModels(channelID, true)
}
func (s *Service) SystemChannelModel(channelID string, modelKey string) (*model.ChannelModel, error) {
return s.repo.ChannelModelByKey(channelID, strings.TrimPrefix(strings.TrimSpace(modelKey), "models/"))
}
func (s *Service) FetchAdminChannelModels(ctx context.Context, actor *model.User, channelID string) (*AdminChannelModelFetchResult, error) {
if err := s.RequireAdmin(actor); err != nil {
return nil, err
@@ -86,7 +90,7 @@ func (s *Service) FetchAdminChannelModels(ctx context.Context, actor *model.User
continue
}
// 自动发现不能绕过定价边界;新模型由管理员定价后再手动启用。
missing = append(missing, model.ChannelModel{ID: newID(), ChannelID: channelID, ModelKey: name, DisplayName: name, Capability: capabilityForChannel(*channel), Protocol: channel.InterfaceType, BillingMode: "fixed_request", Enabled: false, PriceVersion: 1})
missing = append(missing, model.ChannelModel{ID: newID(), ChannelID: channelID, ModelKey: name, DisplayName: name, BillingMode: "fixed_request", Enabled: false, PriceVersion: 1})
}
added, err := s.repo.CreateMissingChannelModels(missing)
if err != nil {
@@ -108,19 +112,16 @@ func (s *Service) SaveAdminChannelModel(actor *model.User, channelID string, id
return nil, BadAuthRequest("请填写模型标识")
}
capability := normalizeCapability(req.Capability)
if capability == "" {
capability = capabilityForChannel(*channel)
}
if capability == "" {
return nil, BadAuthRequest("请选择模型能力")
}
protocol := model.ChannelInterfaceType(strings.TrimSpace(req.Protocol))
if protocol == "" {
protocol = channel.InterfaceType
}
if !validChannelInterfaceType(protocol) {
return nil, BadAuthRequest("请选择有效的模型请求协议")
}
if (protocol == model.ChannelInterfaceVolcengineJiMengImage || protocol == model.ChannelInterfaceVolcengineJiMengVideo) && (strings.TrimSpace(channel.APIKey) == "" || strings.TrimSpace(channel.SecretKey) == "") {
return nil, BadAuthRequest("即梦官方协议需要先在渠道中配置 Access Key 和 Secret Key")
}
if expected := capabilityForProtocol(protocol); expected != "" && expected != capability {
return nil, BadAuthRequest("模型能力与请求协议不匹配")
}
@@ -228,7 +229,7 @@ func (s *Service) syncInitialChannelModels(channel *model.ModelChannel, names []
}
continue
}
item := model.ChannelModel{ID: newID(), ChannelID: channel.ID, ModelKey: name, DisplayName: name, Capability: capabilityForChannel(*channel), Protocol: channel.InterfaceType, BillingMode: "fixed_request", Enabled: true, PriceVersion: 1}
item := model.ChannelModel{ID: newID(), ChannelID: channel.ID, ModelKey: name, DisplayName: name, BillingMode: "fixed_request", Enabled: false, PriceVersion: 1}
if err := s.repo.SaveChannelModel(&item); err != nil {
return err
}
@@ -277,17 +278,13 @@ func (s *Service) syncChannelModelNames(channel *model.ModelChannel) error {
return s.repo.Save(channel)
}
func capabilityForChannel(channel model.ModelChannel) string {
return capabilityForProtocol(channel.InterfaceType)
}
func capabilityForProtocol(protocol model.ChannelInterfaceType) string {
switch protocol {
case model.ChannelInterfaceOpenAIImage:
case model.ChannelInterfaceOpenAIImage, model.ChannelInterfaceVolcengineArkImage, model.ChannelInterfaceVolcengineJiMengImage:
return "image"
case model.ChannelInterfaceOpenAIAudio:
return "audio"
case model.ChannelInterfaceNewAPIVideo, model.ChannelInterfaceNewAPIChannel1, model.ChannelInterfaceNewAPIChannel2, model.ChannelInterfaceXAIVideo, model.ChannelInterfaceGeminiVeo:
case model.ChannelInterfaceNewAPIVideo, model.ChannelInterfaceNewAPIChannel1, model.ChannelInterfaceNewAPIChannel2, model.ChannelInterfaceXAIVideo, model.ChannelInterfaceVolcengineArkVideo, model.ChannelInterfaceVolcengineJiMengVideo, model.ChannelInterfaceGeminiVeo:
return "video"
case model.ChannelInterfaceChatCompletion, model.ChannelInterfaceOpenAIResponse:
return "text"
+3
View File
@@ -396,6 +396,9 @@ func (s *Service) newBillingOrder(userID string, taskID string, idempotencyKey s
if !item.PriceConfigured {
return nil, BadAuthRequest("当前模型尚未配置用户积分价格")
}
if item.Protocol == model.ChannelInterfaceVolcengineJiMengVideo && requestedQuantity != 5 && requestedQuantity != 10 {
return nil, BadAuthRequest("即梦视频仅支持 5 秒或 10 秒,请调整视频时长")
}
quantity := int64(1)
switch item.BillingMode {
case "fixed_request":
+116 -10
View File
@@ -8,6 +8,7 @@ import (
"errors"
"fmt"
"io"
"math"
"mime"
"mime/multipart"
"net/http"
@@ -37,6 +38,7 @@ type providerConfig struct {
InterfaceType string `json:"interfaceType"`
BaseURL string `json:"baseUrl"`
APIKey string `json:"apiKey"`
SecretKey string `json:"secretKey"`
Model string `json:"model"`
Size string `json:"size"`
Quality string `json:"quality"`
@@ -167,8 +169,11 @@ func (s *Service) processCanvasGenerationTask(ctx context.Context, userID string
if err := validateGenerationInterface(input.Mode, input.Config.InterfaceType); err != nil {
return nil, err
}
if isVolcengineJiMengProtocol(input.Config.InterfaceType) && strings.TrimSpace(input.Config.SecretKey) == "" {
return nil, errors.New("即梦官方 API 缺少 Secret Key")
}
if resumedProviderRequestID(ctx) == "" {
requirePublicURL := input.Config.InterfaceType == "newapi-channel-1" || input.Config.InterfaceType == "newapi-channel-2"
requirePublicURL := input.Config.InterfaceType == "newapi-channel-1" || input.Config.InterfaceType == "newapi-channel-2" || input.Config.InterfaceType == string(model.ChannelInterfaceVolcengineArkVideo)
if err := s.hydrateGenerationMedia(userID, &input, requirePublicURL); err != nil {
return nil, err
}
@@ -300,10 +305,11 @@ func (s *Service) resolveProviderConfig(config providerConfig) (providerConfig,
}
config.ChannelID = channel.ID
config.APIFormat = channel.APIFormat
config.InterfaceType = string(channel.InterfaceType)
if channelModel, modelErr := s.repo.ChannelModelByKey(channel.ID, modelName); modelErr == nil && channelModel.Protocol != "" {
config.InterfaceType = string(channelModel.Protocol)
channelModel, modelErr := s.repo.ChannelModelByKey(channel.ID, modelName)
if modelErr != nil || channelModel.Protocol == "" {
return providerConfig{}, errors.New("当前模型尚未配置请求协议")
}
config.InterfaceType = string(channelModel.Protocol)
// 模型协议是实际请求契约;混合渠道中鉴权格式也必须随模型协议切换。
if config.InterfaceType == string(model.ChannelInterfaceGeminiVeo) {
config.APIFormat = "gemini"
@@ -312,6 +318,7 @@ func (s *Service) resolveProviderConfig(config providerConfig) (providerConfig,
}
config.BaseURL = channel.BaseURL
config.APIKey = channel.APIKey
config.SecretKey = channel.SecretKey
config.Model = modelName
return config, nil
}
@@ -343,6 +350,12 @@ func systemChannelIDFromBaseURL(baseURL string) string {
}
func runImageTask(ctx context.Context, input canvasGenerationInput) (map[string]interface{}, error) {
if input.Config.InterfaceType == string(model.ChannelInterfaceVolcengineJiMengImage) {
return runVolcengineJiMengImageTask(ctx, input)
}
if input.Config.InterfaceType == string(model.ChannelInterfaceVolcengineArkImage) {
return runVolcengineArkImageTask(ctx, input)
}
var payload imageResponse
if input.Mask != nil {
// 蒙版编辑是强校验写路径:协议能力不明确时必须失败,不能静默退化为整图重绘。
@@ -414,6 +427,82 @@ func runImageTask(ctx context.Context, input canvasGenerationInput) (map[string]
return map[string]interface{}{"mode": "image", "images": images}, nil
}
const volcengineArkImageMaxPixels = 4624220
func runVolcengineArkImageTask(ctx context.Context, input canvasGenerationInput) (map[string]interface{}, error) {
if input.Mask != nil {
return nil, errors.New("火山方舟图片协议不支持蒙版编辑,请移除蒙版后重试")
}
body, err := volcengineArkImageBody(input)
if err != nil {
return nil, err
}
var payload imageResponse
if err := postJSON(ctx, input.Config, "/images/generations", body, &payload); err != nil {
return nil, err
}
images, err := imageDataURLs(payload)
if err != nil {
return nil, err
}
return map[string]interface{}{"mode": "image", "images": images}, nil
}
func volcengineArkImageBody(input canvasGenerationInput) (map[string]interface{}, error) {
body := map[string]interface{}{
"model": input.Config.Model,
"prompt": withSystemPrompt(input.Config, input.Prompt),
"n": 1,
}
if size := normalizeVolcengineArkImageSize(input.Config.Size); size != "" {
body["size"] = size
}
if len(input.ReferenceImages) == 0 {
return body, nil
}
images := make([]string, 0, len(input.ReferenceImages))
for _, image := range input.ReferenceImages {
url, err := openAIImageInputURL(image)
if err != nil {
return nil, err
}
images = append(images, url)
}
if len(images) == 1 {
body["image"] = images[0]
} else {
body["image"] = images
}
return body, nil
}
func normalizeVolcengineArkImageSize(value string) string {
size := normalizePixelSize(value)
parts := strings.Split(strings.ToLower(size), "x")
if len(parts) != 2 {
return size
}
width, widthErr := strconv.Atoi(parts[0])
height, heightErr := strconv.Atoi(parts[1])
if widthErr != nil || heightErr != nil || width <= 0 || height <= 0 {
return size
}
if int64(width)*int64(height) <= volcengineArkImageMaxPixels {
return size
}
scale := math.Sqrt(float64(volcengineArkImageMaxPixels) / (float64(width) * float64(height)))
width = int(math.Floor(float64(width)*scale/2)) * 2
height = int(math.Floor(float64(height)*scale/2)) * 2
for width > 2 && height > 2 && int64(width)*int64(height) > volcengineArkImageMaxPixels {
if width >= height {
width -= 2
} else {
height -= 2
}
}
return strconv.Itoa(width) + "x" + strconv.Itoa(height)
}
func runTextTask(ctx context.Context, input canvasGenerationInput) (map[string]interface{}, error) {
switch input.Config.InterfaceType {
case "chat-completion":
@@ -596,6 +685,9 @@ func runAudioTask(ctx context.Context, input canvasGenerationInput) (map[string]
}
func runVideoTask(ctx context.Context, input canvasGenerationInput) (map[string]interface{}, error) {
if input.Config.InterfaceType == string(model.ChannelInterfaceVolcengineJiMengVideo) {
return runVolcengineJiMengVideoTask(ctx, input)
}
if input.Config.InterfaceType == "gemini-veo" {
return runGeminiVeoVideoTask(ctx, input)
}
@@ -605,6 +697,9 @@ func runVideoTask(ctx context.Context, input canvasGenerationInput) (map[string]
if input.Config.InterfaceType == "newapi-channel-1" {
return runNewAPIChannel1VideoTask(ctx, input)
}
if input.Config.InterfaceType == string(model.ChannelInterfaceVolcengineArkVideo) {
return runSeedanceAgentPlanVideoTask(ctx, input)
}
if isArkPlanVideoConfig(input.Config) {
return runSeedanceAgentPlanVideoTask(ctx, input)
}
@@ -1173,8 +1268,8 @@ func validateGenerationInterface(mode string, interfaceType string) error {
}
allowed := map[string]map[string]bool{
"text": {"chat-completion": true, "openai-response": true},
"image": {"openai-image": true},
"video": {"newapi": true, "newapi-channel-1": true, "newapi-channel-2": true, "xai-video": true, "gemini-veo": true},
"image": {"openai-image": true, "volcengine-ark-image": true, "volcengine-jimeng-image": true},
"video": {"newapi": true, "newapi-channel-1": true, "newapi-channel-2": true, "xai-video": true, "volcengine-ark-video": true, "volcengine-jimeng-video": true, "gemini-veo": true},
"audio": {"openai-audio": true},
}
if allowed[mode] != nil && !allowed[mode][interfaceType] {
@@ -1294,6 +1389,10 @@ func runSeedanceVideosTask(ctx context.Context, input canvasGenerationInput) (ma
}
func runSeedanceAgentPlanVideoTask(ctx context.Context, input canvasGenerationInput) (map[string]interface{}, error) {
providerName := "Seedance"
if input.Config.InterfaceType == string(model.ChannelInterfaceVolcengineArkVideo) {
providerName = "火山方舟"
}
id := resumedProviderRequestID(ctx)
var created map[string]interface{}
if id == "" {
@@ -1301,6 +1400,13 @@ func runSeedanceAgentPlanVideoTask(ctx context.Context, input canvasGenerationIn
if err != nil {
return nil, err
}
if input.Config.InterfaceType == string(model.ChannelInterfaceVolcengineArkVideo) {
for _, item := range content {
if item["type"] == "image_url" {
item["role"] = "reference_image"
}
}
}
body := map[string]interface{}{
"model": input.Config.Model,
"content": content,
@@ -1319,7 +1425,7 @@ func runSeedanceAgentPlanVideoTask(ctx context.Context, input canvasGenerationIn
id = stringField(created, "id")
}
if id == "" {
return nil, errors.New("Seedance 接口没有返回任务 ID")
return nil, fmt.Errorf("%s接口没有返回任务 ID", providerName)
}
for deadline := providerPollingDeadline(ctx); time.Now().Before(deadline); {
var state map[string]interface{}
@@ -1334,7 +1440,7 @@ func runSeedanceAgentPlanVideoTask(ctx context.Context, input canvasGenerationIn
content, _ := state["content"].(map[string]interface{})
videoURL := stringField(content, "video_url")
if videoURL == "" {
return nil, errors.New("Seedance 任务成功但没有返回视频 URL")
return nil, fmt.Errorf("%s任务成功但没有返回视频 URL", providerName)
}
data, mimeType, err := getExternalBinary(withProviderRequestKind(ctx, "download"), videoURL)
if err != nil {
@@ -1343,13 +1449,13 @@ func runSeedanceAgentPlanVideoTask(ctx context.Context, input canvasGenerationIn
return map[string]interface{}{"mode": "video", "video": map[string]interface{}{"dataUrl": dataURL(mimeType, data), "mimeType": mimeType}}, nil
}
if status == "failed" || status == "cancelled" || status == "expired" {
return nil, errors.New("Seedance 视频生成失败")
return nil, fmt.Errorf("%s视频生成失败", providerName)
}
if err := sleepContext(ctx, 5*time.Second); err != nil {
return nil, err
}
}
return nil, errors.New("Seedance 视频生成超时")
return nil, fmt.Errorf("%s视频生成超时", providerName)
}
func postJSON(ctx context.Context, config providerConfig, path string, body interface{}, target interface{}) error {
+304
View File
@@ -0,0 +1,304 @@
package service
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"math"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"github.com/volcengine/volc-sdk-golang/base"
)
const (
jiMengSubmitAction = "CVSync2AsyncSubmitTask"
jiMengResultAction = "CVSync2AsyncGetResult"
jiMengAPIVersion = "2022-08-31"
)
type jiMengResponse struct {
Code int `json:"code"`
Message string `json:"message"`
RequestID string `json:"request_id"`
Data struct {
TaskID string `json:"task_id"`
Status string `json:"status"`
BinaryDataBase64 []string `json:"binary_data_base64"`
ImageURLs []string `json:"image_urls"`
VideoURL string `json:"video_url"`
} `json:"data"`
}
func isVolcengineJiMengProtocol(protocol string) bool {
return protocol == "volcengine-jimeng-image" || protocol == "volcengine-jimeng-video"
}
func runVolcengineJiMengImageTask(ctx context.Context, input canvasGenerationInput) (map[string]interface{}, error) {
if input.Mask != nil {
return nil, errors.New("即梦图片协议不支持蒙版编辑,请移除蒙版后重试")
}
body := map[string]interface{}{
"req_key": input.Config.Model,
"prompt": withSystemPrompt(input.Config, input.Prompt),
"force_single": true,
}
if width, height := jiMengImageDimensions(input.Config.Size); width > 0 && height > 0 {
body["width"] = width
body["height"] = height
}
if len(input.ReferenceImages) > 14 {
return nil, errors.New("即梦图片协议最多支持 14 张参考图")
}
if len(input.ReferenceImages) > 0 {
images := make([]string, 0, len(input.ReferenceImages))
for _, image := range input.ReferenceImages {
raw, _, err := mediaBytes(image)
if err != nil {
return nil, fmt.Errorf("读取即梦参考图失败:%w", err)
}
images = append(images, base64.StdEncoding.EncodeToString(raw))
}
body["binary_data_base64"] = images
}
taskID, err := submitJiMengTask(ctx, input.Config, body)
if err != nil {
return nil, err
}
for deadline := providerPollingDeadline(ctx); time.Now().Before(deadline); {
result, err := pollJiMengTask(ctx, input.Config, taskID, `{"return_url":true}`)
if err != nil {
return nil, err
}
switch strings.ToLower(strings.TrimSpace(result.Data.Status)) {
case "done":
images, err := jiMengImageDataURLs(ctx, result)
if err != nil {
return nil, fmt.Errorf("即梦图片任务 %s 结果读取失败:%w", taskID, err)
}
return map[string]interface{}{"mode": "image", "images": images}, nil
case "not_found", "expired":
return nil, fmt.Errorf("即梦图片任务 %s 已失效,请重新生成", taskID)
}
if err := sleepContext(ctx, 3*time.Second); err != nil {
return nil, err
}
}
return nil, fmt.Errorf("即梦图片生成超时(任务 %s)", taskID)
}
func runVolcengineJiMengVideoTask(ctx context.Context, input canvasGenerationInput) (map[string]interface{}, error) {
if len(input.ReferenceImages) > 1 || len(input.ReferenceVideos) > 0 || len(input.ReferenceAudios) > 0 {
return nil, errors.New("即梦视频协议只支持一张首帧图,不支持参考视频或音频")
}
frames, err := jiMengVideoFrames(input.Config.VideoSeconds)
if err != nil {
return nil, err
}
body := map[string]interface{}{
"req_key": input.Config.Model,
"prompt": strings.TrimSpace(input.Prompt),
"frames": frames,
}
if len(input.ReferenceImages) == 0 {
body["aspect_ratio"] = jiMengVideoAspectRatio(input.Config.Size)
} else {
raw, _, err := mediaBytes(input.ReferenceImages[0])
if err != nil {
return nil, fmt.Errorf("读取即梦视频首帧图失败:%w", err)
}
body["binary_data_base64"] = []string{base64.StdEncoding.EncodeToString(raw)}
}
taskID, err := submitJiMengTask(ctx, input.Config, body)
if err != nil {
return nil, err
}
for deadline := providerPollingDeadline(ctx); time.Now().Before(deadline); {
result, err := pollJiMengTask(ctx, input.Config, taskID, "")
if err != nil {
return nil, err
}
switch strings.ToLower(strings.TrimSpace(result.Data.Status)) {
case "done":
videoURL := strings.TrimSpace(result.Data.VideoURL)
if videoURL == "" {
return nil, fmt.Errorf("即梦视频任务 %s 已完成但没有返回视频地址", taskID)
}
data, mimeType, err := getExternalBinary(withProviderRequestKind(ctx, "download"), videoURL)
if err != nil {
return nil, fmt.Errorf("即梦视频任务 %s 下载失败:%w", taskID, err)
}
mimeType = normalizedMediaMimeType(mimeType, data)
return map[string]interface{}{"mode": "video", "video": map[string]interface{}{"dataUrl": dataURL(mimeType, data), "mimeType": mimeType}}, nil
case "not_found", "expired":
return nil, fmt.Errorf("即梦视频任务 %s 已失效,请重新生成", taskID)
}
if err := sleepContext(ctx, 5*time.Second); err != nil {
return nil, err
}
}
return nil, fmt.Errorf("即梦视频生成超时(任务 %s)", taskID)
}
func submitJiMengTask(ctx context.Context, config providerConfig, body map[string]interface{}) (string, error) {
if resumed := resumedProviderRequestID(ctx); resumed != "" {
return resumed, nil
}
var payload jiMengResponse
if err := postJiMengJSON(withProviderRequestKind(ctx, "create"), config, jiMengSubmitAction, body, &payload); err != nil {
return "", err
}
if err := validateJiMengResponse(payload); err != nil {
return "", err
}
taskID := strings.TrimSpace(payload.Data.TaskID)
if taskID == "" {
return "", errors.New("即梦接口没有返回任务 ID")
}
return taskID, nil
}
func pollJiMengTask(ctx context.Context, config providerConfig, taskID string, reqJSON string) (jiMengResponse, error) {
body := map[string]interface{}{"req_key": config.Model, "task_id": taskID}
if reqJSON != "" {
body["req_json"] = reqJSON
}
var payload jiMengResponse
if err := postJiMengJSON(withProviderRequestKind(ctx, "poll"), config, jiMengResultAction, body, &payload); err != nil {
return payload, err
}
return payload, validateJiMengResponse(payload)
}
func postJiMengJSON(ctx context.Context, config providerConfig, action string, body interface{}, target interface{}) error {
data, err := json.Marshal(body)
if err != nil {
return err
}
endpoint, err := url.Parse(strings.TrimSpace(config.BaseURL))
if err != nil {
return err
}
endpoint.Path = "/"
query := endpoint.Query()
query.Set("Action", action)
query.Set("Version", jiMengAPIVersion)
endpoint.RawQuery = query.Encode()
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint.String(), bytes.NewReader(data))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
credentials := base.Credentials{
AccessKeyID: strings.TrimSpace(config.APIKey),
SecretAccessKey: strings.TrimSpace(config.SecretKey),
Region: "cn-north-1",
Service: "cv",
}
return doJSON(credentials.Sign(req), target)
}
func validateJiMengResponse(payload jiMengResponse) error {
if payload.Code == 10000 {
return nil
}
message := strings.TrimSpace(payload.Message)
if message == "" {
message = "未知错误"
}
if payload.RequestID != "" {
return fmt.Errorf("即梦接口返回错误 %d:%s(request_id: %s)", payload.Code, message, payload.RequestID)
}
return fmt.Errorf("即梦接口返回错误 %d:%s", payload.Code, message)
}
func jiMengImageDataURLs(ctx context.Context, payload jiMengResponse) ([]string, error) {
images := make([]string, 0, len(payload.Data.ImageURLs)+len(payload.Data.BinaryDataBase64))
for _, rawURL := range payload.Data.ImageURLs {
data, mimeType, err := getExternalBinary(withProviderRequestKind(ctx, "download"), rawURL)
if err != nil {
return nil, err
}
images = append(images, dataURL(normalizedMediaMimeType(mimeType, data), data))
}
for _, encoded := range payload.Data.BinaryDataBase64 {
data, err := base64.StdEncoding.DecodeString(strings.TrimSpace(encoded))
if err != nil {
return nil, err
}
images = append(images, dataURL(normalizedMediaMimeType("image/png", data), data))
}
if len(images) == 0 {
return nil, errors.New("任务已完成但没有返回图片")
}
return images, nil
}
func jiMengImageDimensions(value string) (int, int) {
parts := strings.Split(strings.ToLower(normalizePixelSize(value)), "x")
if len(parts) != 2 {
return 0, 0
}
width, widthErr := strconv.Atoi(parts[0])
height, heightErr := strconv.Atoi(parts[1])
if widthErr != nil || heightErr != nil || width <= 0 || height <= 0 {
return 0, 0
}
area := int64(width) * int64(height)
if area < 1024*1024 || area > 4096*4096 {
return 0, 0
}
return width, height
}
func jiMengVideoFrames(value string) (int, error) {
seconds, _ := strconv.Atoi(strings.TrimSpace(value))
switch seconds {
case 5:
return 121, nil
case 10:
return 241, nil
default:
return 0, errors.New("即梦视频仅支持 5 秒或 10 秒,请调整视频时长")
}
}
func jiMengVideoAspectRatio(value string) string {
allowed := []string{"16:9", "4:3", "1:1", "3:4", "9:16", "21:9"}
value = strings.TrimSpace(value)
for _, candidate := range allowed {
if value == candidate {
return candidate
}
}
parts := strings.Split(strings.ToLower(value), "x")
if len(parts) != 2 {
return "16:9"
}
width, widthErr := strconv.ParseFloat(parts[0], 64)
height, heightErr := strconv.ParseFloat(parts[1], 64)
if widthErr != nil || heightErr != nil || width <= 0 || height <= 0 {
return "16:9"
}
ratio := width / height
best := allowed[0]
bestDistance := math.MaxFloat64
for _, candidate := range allowed {
parts := strings.Split(candidate, ":")
left, _ := strconv.ParseFloat(parts[0], 64)
right, _ := strconv.ParseFloat(parts[1], 64)
if distance := math.Abs(ratio - left/right); distance < bestDistance {
best = candidate
bestDistance = distance
}
}
return best
}
@@ -0,0 +1,60 @@
package service
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestVolcengineJiMengImageTaskUsesSignedAsyncProtocol(t *testing.T) {
t.Setenv("CANVAS_ALLOW_PRIVATE_UPSTREAMS", "true")
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !strings.Contains(r.Header.Get("Authorization"), "Credential=AKID/") {
t.Fatalf("Authorization = %q", r.Header.Get("Authorization"))
}
var body map[string]interface{}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatal(err)
}
switch r.URL.Query().Get("Action") {
case jiMengSubmitAction:
if body["req_key"] != "jimeng_seedream46_cvtob" || body["width"] != float64(1024) || body["height"] != float64(1024) {
t.Fatalf("submit body = %#v", body)
}
_, _ = w.Write([]byte(`{"code":10000,"message":"Success","data":{"task_id":"task-1"}}`))
case jiMengResultAction:
if body["task_id"] != "task-1" || body["req_json"] == "" {
t.Fatalf("poll body = %#v", body)
}
_, _ = w.Write([]byte(`{"code":10000,"message":"Success","data":{"status":"done","binary_data_base64":["aGVsbG8="]}}`))
default:
http.Error(w, "unexpected action", http.StatusBadRequest)
}
}))
defer server.Close()
result, err := runVolcengineJiMengImageTask(context.Background(), canvasGenerationInput{
Mode: "image",
Prompt: "一张测试图片",
Config: providerConfig{BaseURL: server.URL, APIKey: "AKID", SecretKey: "SECRET", Model: "jimeng_seedream46_cvtob", InterfaceType: "volcengine-jimeng-image", Size: "1024x1024"},
})
if err != nil {
t.Fatalf("runVolcengineJiMengImageTask() error = %v", err)
}
images, ok := result["images"].([]string)
if !ok || len(images) != 1 || !strings.HasPrefix(images[0], "data:image/png;base64,") {
t.Fatalf("result = %#v", result)
}
}
func TestJiMengVideoFramesRejectsUnsupportedDuration(t *testing.T) {
if frames, err := jiMengVideoFrames("10"); err != nil || frames != 241 {
t.Fatalf("jiMengVideoFrames(10) = %d, %v", frames, err)
}
if _, err := jiMengVideoFrames("6"); err == nil {
t.Fatal("jiMengVideoFrames(6) error = nil")
}
}
+94
View File
@@ -61,6 +61,48 @@ func TestProviderHTTPErrorWarnsAboutUncertain524Billing(t *testing.T) {
}
}
func TestVolcengineArkImageBodyUsesJSONReferencesAndDownscalesSize(t *testing.T) {
body, err := volcengineArkImageBody(canvasGenerationInput{
Prompt: "combine the references",
Config: providerConfig{Model: "doubao-seedream-test", Size: "3840x2160", SystemPrompt: "keep the subject"},
ReferenceImages: []providerMedia{
{URL: "https://example.com/first.png"},
{DataURL: testReferenceImageDataURL},
},
})
if err != nil {
t.Fatalf("volcengineArkImageBody() error = %v", err)
}
images, ok := body["image"].([]string)
if !ok || len(images) != 2 || images[0] != "https://example.com/first.png" || images[1] != testReferenceImageDataURL {
t.Fatalf("image = %#v", body["image"])
}
if body["prompt"] != "keep the subject\n\ncombine the references" {
t.Fatalf("prompt = %q", body["prompt"])
}
size, _ := body["size"].(string)
parts := strings.Split(size, "x")
if len(parts) != 2 {
t.Fatalf("size = %q", size)
}
width, _ := strconv.Atoi(parts[0])
height, _ := strconv.Atoi(parts[1])
if width%2 != 0 || height%2 != 0 || int64(width)*int64(height) > volcengineArkImageMaxPixels {
t.Fatalf("downscaled size = %q", size)
}
}
func TestVolcengineArkImageRejectsMaskBeforeRequest(t *testing.T) {
_, err := runImageTask(context.Background(), canvasGenerationInput{
Prompt: "edit only the masked area",
Config: providerConfig{InterfaceType: "volcengine-ark-image"},
Mask: &providerMedia{DataURL: testReferenceImageDataURL},
})
if err == nil || !strings.Contains(err.Error(), "不支持蒙版") {
t.Fatalf("runImageTask() error = %v", err)
}
}
func TestDoBinaryRejectsOversizedProviderResponse(t *testing.T) {
t.Setenv("CANVAS_ALLOW_PRIVATE_UPSTREAMS", "true")
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
@@ -576,6 +618,58 @@ func TestArkPlanConfigStaysSeparateFromSeedanceVideosEndpoint(t *testing.T) {
}
}
func TestVolcengineArkVideoProtocolUsesContentTaskAndDownloadsResult(t *testing.T) {
t.Setenv("CANVAS_ALLOW_PRIVATE_UPSTREAMS", "true")
paths := make([]string, 0, 3)
var server *httptest.Server
server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
paths = append(paths, r.Method+" "+r.URL.Path)
switch r.Method + " " + r.URL.Path {
case "POST /api/v3/contents/generations/tasks":
var body map[string]interface{}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatalf("decode request: %v", err)
}
content, _ := body["content"].([]interface{})
if len(content) != 2 {
t.Errorf("body = %#v", body)
return
}
imageContent, _ := content[1].(map[string]interface{})
if body["model"] != "doubao-seedance-test" || imageContent["role"] != "reference_image" {
t.Errorf("body = %#v", body)
}
_, _ = w.Write([]byte(`{"id":"ark-task-1","status":"running"}`))
case "GET /api/v3/contents/generations/tasks/ark-task-1":
_, _ = w.Write([]byte(`{"id":"ark-task-1","status":"succeeded","content":{"video_url":"` + server.URL + `/result.mp4"}}`))
case "GET /result.mp4":
w.Header().Set("Content-Type", "video/mp4")
_, _ = w.Write([]byte("video"))
default:
http.NotFound(w, r)
}
}))
defer server.Close()
result, err := runVideoTask(context.Background(), canvasGenerationInput{
Prompt: "make it move",
Config: providerConfig{BaseURL: server.URL + "/api/v3", APIKey: "test-key", Model: "doubao-seedance-test", InterfaceType: "volcengine-ark-video"},
ReferenceImages: []providerMedia{{ID: "start", URL: server.URL + "/reference.png"}},
Metadata: map[string]interface{}{"videoStartFrameNodeId": "start"},
})
if err != nil {
t.Fatalf("runVideoTask() error = %v", err)
}
video := result["video"].(map[string]interface{})
if video["dataUrl"] != "data:video/mp4;base64,dmlkZW8=" {
t.Fatalf("video = %#v", video)
}
want := "POST /api/v3/contents/generations/tasks,GET /api/v3/contents/generations/tasks/ark-task-1,GET /result.mp4"
if got := strings.Join(paths, ","); got != want {
t.Fatalf("paths = %q, want %q", got, want)
}
}
func TestNewAPIChannel1VideoBodyMapsFramesAndReferences(t *testing.T) {
t.Setenv("CANVAS_ALLOW_PRIVATE_UPSTREAMS", "true")
server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))