feat(api): 火山模型协议 - 接入方舟与即梦图片视频生成并支持 AK/SK 凭证
This commit is contained in:
1 parent
9ad2fb1b99
commit
c33e05ff63
29 files changed
+1739
-310
No files matched your search
@@ -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),
|
||||
|
||||
@@ -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/") {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 仍代表可独立创作的画布文档。
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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) {}))
|
||||
|
||||
Reference in new issue
Block a user