| Auteur | SHA1 | Bericht | Datum |
|---|---|---|---|
|
|
6f6c68787f |
feat(volcengine): manage per-user asset groups on DoubaoVideo channels
Bring official Volcengine channels in line with the China Mobile asset isolation model: the platform owns the upstream asset group lifecycle per (user, channel). asset_group.* APIs are forbidden for clients; CreateAsset is scoped to the user's managed group (client GroupId is overwritten), ListAssets is scoped with Filter.GroupIds to the managed group, and Get/Update/Delete verify ownership via GetAsset before forwarding, returning not-found for foreign assets. Generalize the shared managed-group plumbing (GetOrCreateUserAssetGroup, ScopeManagedAssetRequest, RequireManagedAssetOwnership) and add the DoubaoVideo-specific group creator (Ark CreateAssetGroup returns the id in Result.Id; GroupType must be omitted). The DoubaoVideo asset adapter now serializes the request body from req.Body so platform rewrites take effect (RawBody was bypassing the scoping) and the V4 signature always covers the actual payload. Verified end-to-end: group ops rejected, forged GroupId overwritten by the auto-created managed group, ListAssets returns only the managed group, foreign assets hidden from get/delete. Co-Authored-By: ZCode <noreply@anthropic.com> |
1 maand geleden |
|
|
b9edaa1ad3 |
feat(volcengine): add asset library support for official DoubaoVideo channels
Register a DoubaoVideo asset adapter that forwards the Ark-compatible asset API (POST /api/v1/volcengine/asset?Action=xx) to the official /openApi/portrait endpoint with a Volcengine V4 HMAC-SHA256 signature (canonical request over content-type/host/x-content-sha256/x-date, date/cn-beijing/ark/request scope). The AK/SK pair and optional project code are stored in channel_asset_credentials (pool_id holds the project code), so asset credentials stay separate from the video Bearer key; the channel form now accepts them for type 54. DoubaoVideo joins the seedance asset family so a user's asset upload channel and video task channel stay the same (asset:// references pass through to the upstream). Both resolution chains (asset proxy resolver and video asset family matcher) now require the asset credential to be configured before a type-54 channel can serve, bind, or be auto-matched for assets, falling through to compatible channels otherwise. The /api/v3 native-path whitelist simplifies back to the family list now that 54 is a member. Verified end-to-end against the real upstream: CreateAssetGroup, CreateAsset (real video upload, Processing -> Active moderation), ListAssets/GetAsset, and a video generation task referencing the asset via asset://video reference (succeeded, 432900 tokens). Co-Authored-By: ZCode <noreply@anthropic.com> |
1 maand geleden |
|
|
b2d43b6c29 |
fix(doubao-video): parse camelCase fields from Volcengine task responses
The taskdoubao adaptor expected snake_case JSON keys (video_url, completion_tokens, total_tokens, framespersecond, service_tier, created_at, updated_at) but Volcengine returns camelCase (videoUrl, completionTokens, totalTokens, framesPerSecond, serviceTier, createdAt, updatedAt). As a result the video URL was never parsed: successful tasks fell back to the platform proxy URL (BuildProxyURL) as result_url, which broke the video proxy in containerized deployments (localhost:port not reachable inside the container) and zeroed the usage tokens used for billing. Align the responseTask JSON tags with the real Volcengine response shape and add a regression test with the exact production payload. Co-Authored-By: ZCode <noreply@anthropic.com> |
1 maand geleden |
|
|
12686067c5 |
feat(volcengine): serve native /api/v3 video path on official DoubaoVideo channels
The native /api/v3/contents/generations/tasks route was restricted to the seedance asset channel family (58/60/61); official Volcengine Ark channels (DoubaoVideo, type 54) could only be reached via the standardized /v1/video/generations path. Allow type 54 on the native route in both the distributor and the task retry channel selection, while keeping it out of the seedance asset binding system. The taskdoubao adaptor now reuses a pre-parsed task request from the context (native path) instead of re-parsing the body as TaskSubmitReq, whose prompt validation would reject the Volcengine-native format. When req.Prompt is empty (native path), content text items from metadata are preserved instead of being replaced with an empty prompt; the standardized path behavior (prompt replaces metadata text) is unchanged and covered by existing tests. Co-Authored-By: ZCode <noreply@anthropic.com> |
1 maand geleden |
| @@ -726,13 +726,23 @@ type ChannelAssetCredentialInput struct { | |||
| } | |||
| func channelAssetCredentialFromInput(channelType int, input *ChannelAssetCredentialInput) (*model.ChannelAssetCredential, error) { | |||
| if channelType != constant.ChannelTypeChinaMobileSeedance || input == nil { | |||
| if input == nil { | |||
| return nil, nil | |||
| } | |||
| var label string | |||
| switch channelType { | |||
| case constant.ChannelTypeChinaMobileSeedance: | |||
| label = "移动云素材" | |||
| case constant.ChannelTypeDoubaoVideo: | |||
| label = "火山素材" | |||
| default: | |||
| // Other channel types do not use separate asset credentials. | |||
| return nil, nil | |||
| } | |||
| ak := strings.TrimSpace(input.AccessKey) | |||
| sk := strings.TrimSpace(input.SecretKey) | |||
| if ak == "" || sk == "" { | |||
| return nil, errors.New("移动云素材 AccessKey 和 SecretKey 必须同时填写") | |||
| return nil, errors.New(label + " AccessKey 和 SecretKey 必须同时填写") | |||
| } | |||
| return &model.ChannelAssetCredential{AccessKey: ak, SecretKey: sk, PoolID: strings.TrimSpace(input.PoolID)}, nil | |||
| } | |||
| @@ -33,4 +33,10 @@ func TestRequiredTaskChannelTypeForTianyiYunSeedanceModelUsesAllowedFamily(t *te | |||
| service.VideoAssetChannelTypesForFamily(service.VideoAssetFamilySeedance), | |||
| allowedTaskChannelTypesForRequest(c), | |||
| ) | |||
| // The seedance family must include the official Volcengine channel so the | |||
| // native /api/v3 path can route to it. | |||
| require.Contains(t, | |||
| allowedTaskChannelTypesForRequest(c), | |||
| constant.ChannelTypeDoubaoVideo, | |||
| ) | |||
| } | |||
| @@ -107,21 +107,29 @@ func DoubaoAssetProxy(c *gin.Context) { | |||
| Body: body, | |||
| RawBody: rawBody, | |||
| } | |||
| if service.IsChinaMobileAssetChannel(channel) { | |||
| if service.IsChinaMobileAssetChannel(channel) || service.IsDoubaoVideoAssetChannel(channel) { | |||
| if service.IsAssetGroupOperation(action.Operation) { | |||
| assetProxyError(c, http.StatusForbidden, service.AssetErrorOperationNotSupported, "China Mobile asset group APIs are managed by the platform") | |||
| assetProxyError(c, http.StatusForbidden, service.AssetErrorOperationNotSupported, "asset group APIs are managed by the platform") | |||
| return | |||
| } | |||
| groupID, assetErr := service.GetOrCreateChinaMobileUserAssetGroup(c.Request.Context(), userID, channel, func(ctx context.Context, userID int, channel *model.Channel) (string, *service.AssetError) { | |||
| return service.CreateChinaMobileUserAssetGroup(ctx, userID, adapter, channel) | |||
| }) | |||
| var createGroup service.ManagedAssetGroupCreator | |||
| if service.IsChinaMobileAssetChannel(channel) { | |||
| createGroup = func(ctx context.Context, userID int, ch *model.Channel) (string, *service.AssetError) { | |||
| return service.CreateChinaMobileUserAssetGroup(ctx, userID, adapter, ch) | |||
| } | |||
| } else { | |||
| createGroup = func(ctx context.Context, userID int, ch *model.Channel) (string, *service.AssetError) { | |||
| return service.CreateDoubaoVideoUserAssetGroup(ctx, userID, adapter, ch) | |||
| } | |||
| } | |||
| groupID, assetErr := service.GetOrCreateUserAssetGroup(c.Request.Context(), userID, channel, createGroup) | |||
| if assetErr != nil { | |||
| assetProxyError(c, assetErr.HTTPStatus, assetErr.Type, assetErr.Message) | |||
| return | |||
| } | |||
| service.ScopeChinaMobileAssetRequest(&assetRequest, groupID) | |||
| service.ScopeManagedAssetRequest(&assetRequest, groupID) | |||
| if action.Operation == service.AssetOperationAssetGet || action.Operation == service.AssetOperationAssetUpdate || action.Operation == service.AssetOperationAssetDelete { | |||
| if assetErr := service.RequireChinaMobileAssetOwnership(c.Request.Context(), adapter, channel, assetRequest, groupID); assetErr != nil { | |||
| if assetErr := service.RequireManagedAssetOwnership(c.Request.Context(), adapter, channel, assetRequest, groupID); assetErr != nil { | |||
| assetProxyError(c, assetErr.HTTPStatus, assetErr.Type, assetErr.Message) | |||
| return | |||
| } | |||
| @@ -367,6 +367,7 @@ func requiredTaskChannelTypeForRequest(c *gin.Context) int { | |||
| func allowedTaskChannelTypesForRequest(c *gin.Context) []int { | |||
| if strings.HasPrefix(c.Request.URL.Path, "/api/v3/contents/generations/tasks") { | |||
| // Keep in sync with allowedChannelTypesForRequest in middleware/distributor.go | |||
| return service.VideoAssetChannelTypesForFamily(service.VideoAssetFamilySeedance) | |||
| } | |||
| if isKlingAipingNativePath(c.Request.URL.Path) { | |||
| @@ -216,6 +216,12 @@ func TestSeedanceTasksUseAllowedFamilyTypesInsteadOfModelPrefix(t *testing.T) { | |||
| service.VideoAssetChannelTypesForFamily(service.VideoAssetFamilySeedance), | |||
| allowedChannelTypesForRequest(c, "Doubao-Seedance-2.0"), | |||
| ) | |||
| // The seedance family must include the official Volcengine channel so the | |||
| // native /api/v3 path can route to it. | |||
| require.Contains(t, | |||
| allowedChannelTypesForRequest(c, "Doubao-Seedance-2.0"), | |||
| constant.ChannelTypeDoubaoVideo, | |||
| ) | |||
| } | |||
| func TestShouldPersistDoubaoVideoBindingRequiresVideoSubmitRelayMode(t *testing.T) { | |||
| @@ -6,6 +6,7 @@ import ( | |||
| "io" | |||
| "net/http" | |||
| "strconv" | |||
| "strings" | |||
| "time" | |||
| "github.com/QuantumNous/new-api/common" | |||
| @@ -70,24 +71,24 @@ type responseTask struct { | |||
| Model string `json:"model"` | |||
| Status string `json:"status"` | |||
| Content struct { | |||
| VideoURL string `json:"video_url"` | |||
| VideoURL string `json:"videoUrl"` | |||
| } `json:"content"` | |||
| Seed int `json:"seed"` | |||
| Resolution string `json:"resolution"` | |||
| Duration int `json:"duration"` | |||
| Ratio string `json:"ratio"` | |||
| FramesPerSecond int `json:"framespersecond"` | |||
| ServiceTier string `json:"service_tier"` | |||
| FramesPerSecond int `json:"framesPerSecond"` | |||
| ServiceTier string `json:"serviceTier"` | |||
| Usage struct { | |||
| CompletionTokens int `json:"completion_tokens"` | |||
| TotalTokens int `json:"total_tokens"` | |||
| CompletionTokens int `json:"completionTokens"` | |||
| TotalTokens int `json:"totalTokens"` | |||
| } `json:"usage"` | |||
| Error struct { | |||
| Code string `json:"code"` | |||
| Message string `json:"message"` | |||
| } `json:"error"` | |||
| CreatedAt int64 `json:"created_at"` | |||
| UpdatedAt int64 `json:"updated_at"` | |||
| CreatedAt int64 `json:"createdAt"` | |||
| UpdatedAt int64 `json:"updatedAt"` | |||
| } | |||
| // ============================ | |||
| @@ -109,7 +110,14 @@ func (a *TaskAdaptor) Init(info *relaycommon.RelayInfo) { | |||
| // ValidateRequestAndSetAction parses body, validates fields and sets default action. | |||
| func (a *TaskAdaptor) ValidateRequestAndSetAction(c *gin.Context, info *relaycommon.RelayInfo) (taskErr *dto.TaskError) { | |||
| // Accept only POST /v1/video/generations as "generate" action. | |||
| // The native /api/v3/contents/generations/tasks path pre-parses the | |||
| // Volcengine-native body and stores it in the context; reuse it instead of | |||
| // re-parsing the body as TaskSubmitReq (whose prompt validation would fail, | |||
| // since the native prompt lives inside content[].text). | |||
| if _, err := relaycommon.GetTaskRequest(c); err == nil { | |||
| info.Action = constant.TaskActionGenerate | |||
| return nil | |||
| } | |||
| return relaycommon.ValidateBasicTaskRequest(c, info, constant.TaskActionGenerate) | |||
| } | |||
| @@ -245,11 +253,16 @@ func (a *TaskAdaptor) convertToRequestPayload(req *relaycommon.TaskSubmitReq) (* | |||
| r.Duration = lo.ToPtr(dto.IntValue(sec)) | |||
| } | |||
| r.Content = lo.Reject(r.Content, func(c ContentItem, _ int) bool { return c.Type == "text" }) | |||
| r.Content = append(r.Content, ContentItem{ | |||
| Type: "text", | |||
| Text: req.Prompt, | |||
| }) | |||
| // An explicit prompt replaces any text item from metadata. An empty prompt | |||
| // only happens on the native /api/v3 path, where the prompt already lives | |||
| // in a content text item that must be preserved as-is. | |||
| if strings.TrimSpace(req.Prompt) != "" { | |||
| r.Content = lo.Reject(r.Content, func(c ContentItem, _ int) bool { return c.Type == "text" }) | |||
| r.Content = append(r.Content, ContentItem{ | |||
| Type: "text", | |||
| Text: req.Prompt, | |||
| }) | |||
| } | |||
| return &r, nil | |||
| } | |||
| @@ -13,8 +13,8 @@ func TestParseTaskResult_SuccessWithUsage(t *testing.T) { | |||
| adaptor := &TaskAdaptor{} | |||
| taskInfo, err := adaptor.ParseTaskResult([]byte(`{ | |||
| "status":"succeeded", | |||
| "content":{"video_url":"https://example.test/video.mp4"}, | |||
| "usage":{"completion_tokens":12,"total_tokens":3456} | |||
| "content":{"videoUrl":"https://example.test/video.mp4"}, | |||
| "usage":{"completionTokens":12,"totalTokens":3456} | |||
| }`)) | |||
| require.NoError(t, err) | |||
| @@ -28,7 +28,7 @@ func TestParseTaskResult_SuccessWithoutUsage(t *testing.T) { | |||
| adaptor := &TaskAdaptor{} | |||
| taskInfo, err := adaptor.ParseTaskResult([]byte(`{ | |||
| "status":"succeeded", | |||
| "content":{"video_url":"https://example.test/video.mp4"} | |||
| "content":{"videoUrl":"https://example.test/video.mp4"} | |||
| }`)) | |||
| require.NoError(t, err) | |||
| @@ -145,6 +145,66 @@ func TestConvertToRequestPayload_PromptAppendedAfterMetadataAndReplacesMetadataT | |||
| require.Equal(t, "current prompt", payload.Content[1].Text) | |||
| } | |||
| // Native /api/v3/contents/generations/tasks requests store the prompt inside a | |||
| // content text item while req.Prompt stays empty; such text items must survive. | |||
| func TestConvertToRequestPayload_EmptyPromptKeepsNativeContentText(t *testing.T) { | |||
| adaptor := &TaskAdaptor{} | |||
| req := &relaycommon.TaskSubmitReq{ | |||
| Model: "doubao-seedance-2-0-260128", | |||
| Metadata: map[string]interface{}{ | |||
| "content": []interface{}{ | |||
| map[string]interface{}{ | |||
| "type": "text", | |||
| "text": "一只猫在打哈欠", | |||
| }, | |||
| map[string]interface{}{ | |||
| "type": "image_url", | |||
| "image_url": map[string]interface{}{ | |||
| "url": "https://example.test/cat.png", | |||
| }, | |||
| }, | |||
| }, | |||
| "resolution": "1080p", | |||
| "ratio": "16:9", | |||
| }, | |||
| } | |||
| payload, err := adaptor.convertToRequestPayload(req) | |||
| require.NoError(t, err) | |||
| require.Len(t, payload.Content, 2) | |||
| require.Equal(t, "text", payload.Content[0].Type) | |||
| require.Equal(t, "一只猫在打哈欠", payload.Content[0].Text) | |||
| require.Equal(t, "image_url", payload.Content[1].Type) | |||
| require.Equal(t, "https://example.test/cat.png", payload.Content[1].ImageURL.URL) | |||
| require.Equal(t, "1080p", payload.Resolution) | |||
| require.Equal(t, "16:9", payload.Ratio) | |||
| } | |||
| // Image-to-video without any text item must not produce an empty text item. | |||
| func TestConvertToRequestPayload_EmptyPromptWithoutTextContentAppendsNothing(t *testing.T) { | |||
| adaptor := &TaskAdaptor{} | |||
| req := &relaycommon.TaskSubmitReq{ | |||
| Model: "doubao-seedance-2-0-260128", | |||
| Metadata: map[string]interface{}{ | |||
| "content": []interface{}{ | |||
| map[string]interface{}{ | |||
| "type": "image_url", | |||
| "image_url": map[string]interface{}{ | |||
| "url": "https://example.test/cat.png", | |||
| }, | |||
| }, | |||
| }, | |||
| }, | |||
| } | |||
| payload, err := adaptor.convertToRequestPayload(req) | |||
| require.NoError(t, err) | |||
| require.Len(t, payload.Content, 1) | |||
| require.Equal(t, "image_url", payload.Content[0].Type) | |||
| } | |||
| func TestParseTaskResult_FailedUsesUpstreamErrorMessage(t *testing.T) { | |||
| adaptor := &TaskAdaptor{} | |||
| taskInfo, err := adaptor.ParseTaskResult([]byte(`{ | |||
| @@ -181,6 +241,38 @@ func TestConvertToOpenAIVideo_FailedUsesUpstreamError(t *testing.T) { | |||
| require.Contains(t, string(data), `"code":"InvalidParameter"`) | |||
| } | |||
| // Parses the exact Volcengine response shape observed in production: | |||
| // camelCase videoUrl / usage tokens, not snake_case. | |||
| func TestParseTaskResult_RealVolcengineResponse(t *testing.T) { | |||
| adaptor := &TaskAdaptor{} | |||
| taskInfo, err := adaptor.ParseTaskResult([]byte(`{ | |||
| "id":"cgt-20260819170015-glgnd", | |||
| "seed":46303, | |||
| "draft":false, | |||
| "model":"doubao-seedance-2-0-260128", | |||
| "ratio":"16:9", | |||
| "usage":{"totalTokens":216900,"completionTokens":216900}, | |||
| "status":"succeeded", | |||
| "content":{"videoUrl":"https://ark-acg-cn-beijing.tos-cn-beijing.volces.com/output.mp4"}, | |||
| "duration":10, | |||
| "priority":0, | |||
| "createdAt":1787130015, | |||
| "updatedAt":1787130344, | |||
| "resolution":"720p", | |||
| "serviceTier":"default", | |||
| "outputFormat":"mp4", | |||
| "generateAudio":false, | |||
| "framesPerSecond":24, | |||
| "executionExpiresAfter":172800 | |||
| }`)) | |||
| require.NoError(t, err) | |||
| require.Equal(t, string(model.TaskStatusSuccess), taskInfo.Status) | |||
| require.Equal(t, "https://ark-acg-cn-beijing.tos-cn-beijing.volces.com/output.mp4", taskInfo.Url) | |||
| require.Equal(t, 216900, taskInfo.CompletionTokens) | |||
| require.Equal(t, 216900, taskInfo.TotalTokens) | |||
| } | |||
| func TestGetModelList_IncludesSeedance20Models(t *testing.T) { | |||
| adaptor := &TaskAdaptor{} | |||
| models := adaptor.GetModelList() | |||
| @@ -60,6 +60,7 @@ func init() { | |||
| func registerDefaultAssetAdapters() { | |||
| assetAdapters[constant.ChannelTypeChinaMobileSeedance] = NewChinaMobileAssetAdapter() | |||
| assetAdapters[constant.ChannelTypeDoubaoVideo] = NewDoubaoVideoAssetAdapter() | |||
| assetAdapters[constant.ChannelTypeDoubaoVideoCompatibleAiping] = NewCompatibleAssetAdapter("aiping_asset", []AssetOperation{ | |||
| AssetOperationAssetCreate, | |||
| AssetOperationAssetList, | |||
| @@ -0,0 +1,200 @@ | |||
| package service | |||
| import ( | |||
| "bytes" | |||
| "context" | |||
| "crypto/hmac" | |||
| "crypto/sha256" | |||
| "encoding/hex" | |||
| "fmt" | |||
| "io" | |||
| "net/http" | |||
| "net/url" | |||
| "strings" | |||
| "time" | |||
| "github.com/QuantumNous/new-api/common" | |||
| "github.com/QuantumNous/new-api/model" | |||
| "github.com/QuantumNous/new-api/setting/system_setting" | |||
| ) | |||
| // DoubaoVideoAssetAdapter serves the Ark-compatible asset API on official | |||
| // Volcengine (DoubaoVideo) channels. Unlike the aiping adapter (Bearer key), | |||
| // the official endpoint authenticates every request with a Volcengine V4 | |||
| // HMAC-SHA256 signature built from an AccessKey/SecretKey pair stored in | |||
| // channel_asset_credentials (PoolID holds the optional project code). | |||
| type DoubaoVideoAssetAdapter struct { | |||
| operation map[AssetOperation]struct{} | |||
| } | |||
| func NewDoubaoVideoAssetAdapter() AssetAdapter { | |||
| operations := []AssetOperation{ | |||
| AssetOperationAssetCreate, | |||
| AssetOperationAssetList, | |||
| AssetOperationAssetGet, | |||
| AssetOperationAssetUpdate, | |||
| AssetOperationAssetDelete, | |||
| AssetOperationAssetGroupCreate, | |||
| AssetOperationAssetGroupList, | |||
| AssetOperationAssetGroupGet, | |||
| AssetOperationAssetGroupUpdate, | |||
| AssetOperationAssetGroupDelete, | |||
| } | |||
| supported := make(map[AssetOperation]struct{}, len(operations)) | |||
| for _, op := range operations { | |||
| supported[op] = struct{}{} | |||
| } | |||
| return &DoubaoVideoAssetAdapter{operation: supported} | |||
| } | |||
| func (a *DoubaoVideoAssetAdapter) Name() string { | |||
| return "doubao_video_asset" | |||
| } | |||
| func (a *DoubaoVideoAssetAdapter) Supports(operation AssetOperation) bool { | |||
| _, ok := a.operation[operation] | |||
| return ok | |||
| } | |||
| func (a *DoubaoVideoAssetAdapter) DoAssetRequest(ctx context.Context, channel *model.Channel, req AssetRequest) (*AssetUpstreamResponse, *AssetError) { | |||
| if !a.Supports(req.Action.Operation) { | |||
| return nil, newAssetError(AssetErrorOperationNotSupported, fmt.Sprintf("asset operation %s is not supported", req.Action.Operation), http.StatusBadRequest) | |||
| } | |||
| credential, err := model.GetChannelAssetCredential(channel.Id) | |||
| if err != nil { | |||
| return nil, newAssetError(AssetErrorServer, err.Error(), http.StatusInternalServerError) | |||
| } | |||
| if credential == nil || strings.TrimSpace(credential.AccessKey) == "" || strings.TrimSpace(credential.SecretKey) == "" { | |||
| return nil, newAssetError(AssetErrorServer, "Volcengine asset AccessKey and SecretKey are required (configure the channel asset credential)", http.StatusBadRequest) | |||
| } | |||
| upstreamURL, rawQuery, err := buildDoubaoVideoAssetURL(channel, req.Action.Action, req.Version) | |||
| if err != nil { | |||
| return nil, newAssetError(AssetErrorServer, err.Error(), http.StatusInternalServerError) | |||
| } | |||
| // The body is always serialized from req.Body so platform-side rewrites | |||
| // (managed group scoping, ownership checks) take effect; RawBody is | |||
| // intentionally not used here. The V4 signature covers the sent payload. | |||
| body, err := common.Marshal(req.Body) | |||
| if err != nil { | |||
| return nil, newAssetError(AssetErrorServer, err.Error(), http.StatusInternalServerError) | |||
| } | |||
| if len(body) == 0 { | |||
| body = []byte("{}") | |||
| } | |||
| fetchSetting := system_setting.GetFetchSetting() | |||
| if err := common.ValidateURLWithFetchSetting(upstreamURL, fetchSetting.EnableSSRFProtection, fetchSetting.AllowPrivateIp, fetchSetting.DomainFilterMode, fetchSetting.IpFilterMode, fetchSetting.DomainList, fetchSetting.IpList, fetchSetting.AllowedPorts, fetchSetting.ApplyIPFilterForDomain); err != nil { | |||
| return nil, newAssetError(AssetErrorServer, fmt.Sprintf("request blocked: %v", err), http.StatusForbidden) | |||
| } | |||
| httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, upstreamURL, bytes.NewReader(body)) | |||
| if err != nil { | |||
| return nil, newAssetError(AssetErrorServer, err.Error(), http.StatusInternalServerError) | |||
| } | |||
| signDoubaoVideoAssetRequest(httpReq, credential.AccessKey, credential.SecretKey, body, rawQuery) | |||
| if projectCode := strings.TrimSpace(credential.PoolID); projectCode != "" { | |||
| httpReq.Header.Set("X-Project-Code", projectCode) | |||
| } | |||
| client, err := GetHttpClientWithProxy(channel.GetSetting().Proxy) | |||
| if err != nil { | |||
| return nil, newAssetError(AssetErrorServer, err.Error(), http.StatusInternalServerError) | |||
| } | |||
| if client == nil { | |||
| client = http.DefaultClient | |||
| } | |||
| resp, err := client.Do(httpReq) | |||
| if err != nil { | |||
| return nil, newAssetError(AssetErrorUpstream, err.Error(), http.StatusBadGateway) | |||
| } | |||
| defer resp.Body.Close() | |||
| data, err := io.ReadAll(resp.Body) | |||
| if err != nil { | |||
| return nil, newAssetError(AssetErrorUpstream, err.Error(), http.StatusBadGateway) | |||
| } | |||
| if resp.StatusCode < 200 || resp.StatusCode >= 300 { | |||
| return nil, newAssetError(AssetErrorUpstream, string(data), http.StatusBadGateway) | |||
| } | |||
| return &AssetUpstreamResponse{StatusCode: resp.StatusCode, Header: resp.Header, Body: data}, nil | |||
| } | |||
| func buildDoubaoVideoAssetURL(channel *model.Channel, action string, version string) (string, string, error) { | |||
| baseURL := strings.TrimSpace(channel.GetBaseURL()) | |||
| if baseURL == "" { | |||
| baseURL = "https://ark.cn-beijing.volces.com" | |||
| } | |||
| u, err := url.Parse(baseURL) | |||
| if err != nil { | |||
| return "", "", err | |||
| } | |||
| u.Path = strings.TrimRight(u.Path, "/") + "/openApi/portrait" | |||
| if strings.TrimSpace(version) == "" { | |||
| version = "2024-01-01" | |||
| } | |||
| // Action < Version lexicographically, so this raw query is already the | |||
| // sorted canonical form required by the V4 signature. | |||
| rawQuery := "Action=" + url.QueryEscape(action) + "&Version=" + url.QueryEscape(version) | |||
| u.RawQuery = rawQuery | |||
| return u.String(), rawQuery, nil | |||
| } | |||
| // signDoubaoVideoAssetRequest applies the Volcengine V4 HMAC-SHA256 signature | |||
| // to the request: canonical request over (method, path, sorted query, | |||
| // content-type/host/x-content-sha256/x-date headers, body hash) -> string to | |||
| // sign with the {date}/{region}/ark/request scope -> chained HMAC keys. | |||
| func signDoubaoVideoAssetRequest(req *http.Request, accessKey string, secretKey string, body []byte, rawQuery string) { | |||
| now := time.Now().UTC() | |||
| xDate := now.Format("20060102T150405Z") | |||
| shortDate := now.Format("20060102") | |||
| payloadHash := sha256.Sum256(body) | |||
| payloadHashHex := hex.EncodeToString(payloadHash[:]) | |||
| canonicalHeaders := fmt.Sprintf( | |||
| "content-type:application/json\nhost:%s\nx-content-sha256:%s\nx-date:%s\n", | |||
| req.URL.Host, payloadHashHex, xDate, | |||
| ) | |||
| signedHeaders := "content-type;host;x-content-sha256;x-date" | |||
| canonicalRequest := strings.Join([]string{ | |||
| req.Method, | |||
| req.URL.EscapedPath(), | |||
| rawQuery, | |||
| canonicalHeaders, | |||
| signedHeaders, | |||
| payloadHashHex, | |||
| }, "\n") | |||
| scope := shortDate + "/cn-beijing/ark/request" | |||
| canonicalHash := sha256.Sum256([]byte(canonicalRequest)) | |||
| stringToSign := strings.Join([]string{ | |||
| "HMAC-SHA256", | |||
| xDate, | |||
| scope, | |||
| hex.EncodeToString(canonicalHash[:]), | |||
| }, "\n") | |||
| signingKey := volcengineSigningKey(secretKey, shortDate) | |||
| signature := hmac.New(sha256.New, signingKey) | |||
| signature.Write([]byte(stringToSign)) | |||
| req.Header.Set("Content-Type", "application/json") | |||
| req.Header.Set("Accept", "application/json") | |||
| req.Header.Set("X-Date", xDate) | |||
| req.Header.Set("X-Content-Sha256", payloadHashHex) | |||
| req.Header.Set("Authorization", fmt.Sprintf( | |||
| "HMAC-SHA256 Credential=%s/%s, SignedHeaders=%s, Signature=%s", | |||
| accessKey, scope, signedHeaders, hex.EncodeToString(signature.Sum(nil)), | |||
| )) | |||
| } | |||
| func volcengineSigningKey(secretKey string, shortDate string) []byte { | |||
| key := hmacSHA256([]byte(secretKey), shortDate) | |||
| key = hmacSHA256(key, "cn-beijing") | |||
| key = hmacSHA256(key, "ark") | |||
| return hmacSHA256(key, "request") | |||
| } | |||
| func hmacSHA256(key []byte, message string) []byte { | |||
| mac := hmac.New(sha256.New, key) | |||
| mac.Write([]byte(message)) | |||
| return mac.Sum(nil) | |||
| } | |||
| @@ -0,0 +1,93 @@ | |||
| package service | |||
| import ( | |||
| "net/http" | |||
| "net/url" | |||
| "strings" | |||
| "testing" | |||
| "github.com/QuantumNous/new-api/constant" | |||
| "github.com/QuantumNous/new-api/model" | |||
| "github.com/stretchr/testify/require" | |||
| ) | |||
| func TestDoubaoVideoAssetAdapter_SupportsAllOperations(t *testing.T) { | |||
| adapter := NewDoubaoVideoAssetAdapter() | |||
| require.Equal(t, "doubao_video_asset", adapter.Name()) | |||
| for _, op := range []AssetOperation{ | |||
| AssetOperationAssetCreate, | |||
| AssetOperationAssetList, | |||
| AssetOperationAssetGet, | |||
| AssetOperationAssetUpdate, | |||
| AssetOperationAssetDelete, | |||
| AssetOperationAssetGroupCreate, | |||
| AssetOperationAssetGroupList, | |||
| AssetOperationAssetGroupGet, | |||
| AssetOperationAssetGroupUpdate, | |||
| AssetOperationAssetGroupDelete, | |||
| } { | |||
| require.True(t, adapter.Supports(op), "operation %s should be supported", op) | |||
| } | |||
| } | |||
| func TestBuildDoubaoVideoAssetURL(t *testing.T) { | |||
| baseURL := "http://14.103.147.238:19220" | |||
| channel := &model.Channel{Type: constant.ChannelTypeDoubaoVideo, BaseURL: &baseURL} | |||
| rawURL, rawQuery, err := buildDoubaoVideoAssetURL(channel, "ListAssets", "") | |||
| require.NoError(t, err) | |||
| require.Equal(t, "Action=ListAssets&Version=2024-01-01", rawQuery) | |||
| parsed, err := url.Parse(rawURL) | |||
| require.NoError(t, err) | |||
| require.Equal(t, "/openApi/portrait", parsed.Path) | |||
| require.Equal(t, "Action=ListAssets&Version=2024-01-01", parsed.RawQuery) | |||
| } | |||
| func TestBuildDoubaoVideoAssetURL_DefaultBaseAndTrailingSlash(t *testing.T) { | |||
| baseURL := "https://ark.cn-beijing.volces.com/" | |||
| channel := &model.Channel{Type: constant.ChannelTypeDoubaoVideo, BaseURL: &baseURL} | |||
| rawURL, _, err := buildDoubaoVideoAssetURL(channel, "CreateAsset", "2024-01-01") | |||
| require.NoError(t, err) | |||
| require.Equal(t, "https://ark.cn-beijing.volces.com/openApi/portrait?Action=CreateAsset&Version=2024-01-01", rawURL) | |||
| } | |||
| func TestSignDoubaoVideoAssetRequest_HeaderShape(t *testing.T) { | |||
| req, err := http.NewRequest(http.MethodPost, "http://14.103.147.238:19220/openApi/portrait?Action=ListAssets&Version=2024-01-01", nil) | |||
| require.NoError(t, err) | |||
| signDoubaoVideoAssetRequest(req, "AK-test", "SK-test", []byte(`{"Filter":{"GroupType":"AIGC"}}`), "Action=ListAssets&Version=2024-01-01") | |||
| auth := req.Header.Get("Authorization") | |||
| require.True(t, strings.HasPrefix(auth, "HMAC-SHA256 Credential=AK-test/"), "got %s", auth) | |||
| require.Contains(t, auth, "/cn-beijing/ark/request, SignedHeaders=content-type;host;x-content-sha256;x-date, Signature=") | |||
| require.NotEmpty(t, req.Header.Get("X-Date")) | |||
| require.Len(t, req.Header.Get("X-Date"), 16) | |||
| require.Equal(t, "application/json", req.Header.Get("Content-Type")) | |||
| // body hash header must be the hex sha256 of the raw body | |||
| require.Regexp(t, `^[0-9a-f]{64}$`, req.Header.Get("X-Content-Sha256")) | |||
| } | |||
| // The signature must change when any signed input changes (body, key, date). | |||
| func TestSignDoubaoVideoAssetRequest_DeterministicPerInputs(t *testing.T) { | |||
| build := func(body string) string { | |||
| req, _ := http.NewRequest(http.MethodPost, "http://h/openApi/portrait?Action=A&Version=2024-01-01", nil) | |||
| signDoubaoVideoAssetRequest(req, "AK", "SK", []byte(body), "Action=A&Version=2024-01-01") | |||
| return req.Header.Get("Authorization") | |||
| } | |||
| sig1 := build(`{"a":1}`) | |||
| sig2 := build(`{"a":1}`) | |||
| sig3 := build(`{"a":2}`) | |||
| require.Equal(t, sig1[strings.Index(sig1, "Signature="):], sig2[strings.Index(sig2, "Signature="):]) | |||
| require.NotEqual(t, sig1[strings.Index(sig1, "Signature="):], sig3[strings.Index(sig3, "Signature="):]) | |||
| } | |||
| func TestDoubaoVideoAssetAdapter_MissingCredential(t *testing.T) { | |||
| db := setupDoubaoAssetChannelDB(t) | |||
| require.NoError(t, db.AutoMigrate(&model.ChannelAssetCredential{})) | |||
| adapter := NewDoubaoVideoAssetAdapter() | |||
| channel := &model.Channel{Id: 999999, Type: constant.ChannelTypeDoubaoVideo} | |||
| _, assetErr := adapter.DoAssetRequest(t.Context(), channel, AssetRequest{ | |||
| Action: AssetActionSpec{Action: "ListAssets", Operation: AssetOperationAssetList}, | |||
| RawBody: []byte(`{}`), | |||
| }) | |||
| require.NotNil(t, assetErr) | |||
| require.Contains(t, assetErr.Message, "AccessKey and SecretKey are required") | |||
| } | |||
| @@ -4,12 +4,31 @@ import ( | |||
| "errors" | |||
| "fmt" | |||
| "net/http" | |||
| "strings" | |||
| "github.com/QuantumNous/new-api/common" | |||
| "github.com/QuantumNous/new-api/constant" | |||
| "github.com/QuantumNous/new-api/model" | |||
| "gorm.io/gorm" | |||
| ) | |||
| // assetCredentialConfigured guards channel types whose asset adapter signs | |||
| // requests with a dedicated AK/SK pair stored in channel_asset_credentials | |||
| // (currently only official Volcengine DoubaoVideo). Without the credential | |||
| // the channel must not serve, be bound, or be auto-matched for assets; a DB | |||
| // error is treated as not configured so auto-match falls through to other | |||
| // candidate channels. | |||
| func assetCredentialConfigured(channel *model.Channel) bool { | |||
| if channel.Type != constant.ChannelTypeDoubaoVideo { | |||
| return true | |||
| } | |||
| credential, err := model.GetChannelAssetCredential(channel.Id) | |||
| if err != nil || credential == nil { | |||
| return false | |||
| } | |||
| return strings.TrimSpace(credential.AccessKey) != "" && strings.TrimSpace(credential.SecretKey) != "" | |||
| } | |||
| func ResolveAssetChannelForOperation(userID int, tokenGroup string, operation AssetOperation) (*model.Channel, AssetAdapter, *AssetError) { | |||
| bindings, err := model.GetUserAssetChannelsByTypes(userID, RegisteredAssetChannelTypes(), tokenGroup) | |||
| if err != nil { | |||
| @@ -25,7 +44,7 @@ func ResolveAssetChannelForOperation(userID int, tokenGroup string, operation As | |||
| return nil, nil, newAssetError(AssetErrorServer, err.Error(), http.StatusInternalServerError) | |||
| } | |||
| adapter, ok := GetAssetAdapter(channel.Type) | |||
| if channel.Status != common.ChannelStatusEnabled || !assetChannelHasKey(channel) || !MatchDoubaoAssetGroup(channel.GetGroups(), tokenGroup) || !ok { | |||
| if channel.Status != common.ChannelStatusEnabled || !assetChannelHasKey(channel) || !MatchDoubaoAssetGroup(channel.GetGroups(), tokenGroup) || !ok || !assetCredentialConfigured(channel) { | |||
| return nil, nil, newAssetError(AssetErrorBindingInvalid, "bound asset channel is not available for asset library", http.StatusBadGateway) | |||
| } | |||
| if !adapter.Supports(operation) { | |||
| @@ -66,7 +85,7 @@ func autoMatchAssetChannelForOperation(tokenGroup string, operation AssetOperati | |||
| } | |||
| return nil, nil, err | |||
| } | |||
| if channel.Status == common.ChannelStatusEnabled && assetChannelHasKey(channel) && MatchDoubaoAssetGroup(channel.GetGroups(), tokenGroup) { | |||
| if channel.Status == common.ChannelStatusEnabled && assetChannelHasKey(channel) && MatchDoubaoAssetGroup(channel.GetGroups(), tokenGroup) && assetCredentialConfigured(channel) { | |||
| return channel, adapter, nil | |||
| } | |||
| } | |||
| @@ -11,7 +11,9 @@ import ( | |||
| "github.com/QuantumNous/new-api/model" | |||
| ) | |||
| type chinaMobileAssetGroupCreator func(context.Context, int, *model.Channel) (string, *AssetError) | |||
| // ManagedAssetGroupCreator creates an upstream asset group for a user on a | |||
| // channel; implementations differ per upstream (China Mobile, DoubaoVideo). | |||
| type ManagedAssetGroupCreator func(context.Context, int, *model.Channel) (string, *AssetError) | |||
| func IsChinaMobileAssetChannel(channel *model.Channel) bool { | |||
| return channel != nil && channel.Type == constant.ChannelTypeChinaMobileSeedance | |||
| @@ -21,9 +23,13 @@ func IsAssetGroupOperation(operation AssetOperation) bool { | |||
| return strings.HasPrefix(string(operation), "asset_group.") | |||
| } | |||
| func GetOrCreateChinaMobileUserAssetGroup(ctx context.Context, userId int, channel *model.Channel, createGroup chinaMobileAssetGroupCreator) (string, *AssetError) { | |||
| // GetOrCreateUserAssetGroup returns the platform-managed upstream asset group | |||
| // for (user, channel). Shared by managed channels (China Mobile, DoubaoVideo): | |||
| // the first request creates the upstream group via the provided creator and | |||
| // persists the mapping; concurrent first requests reconcile via re-read. | |||
| func GetOrCreateUserAssetGroup(ctx context.Context, userId int, channel *model.Channel, createGroup ManagedAssetGroupCreator) (string, *AssetError) { | |||
| if channel == nil { | |||
| return "", newAssetError(AssetErrorServer, "China Mobile asset channel is required", http.StatusInternalServerError) | |||
| return "", newAssetError(AssetErrorServer, "managed asset channel is required", http.StatusInternalServerError) | |||
| } | |||
| binding, err := model.GetUserAssetGroup(userId, channel.Id) | |||
| if err != nil { | |||
| @@ -38,7 +44,7 @@ func GetOrCreateChinaMobileUserAssetGroup(ctx context.Context, userId int, chann | |||
| return "", assetErr | |||
| } | |||
| if strings.TrimSpace(groupId) == "" { | |||
| return "", newAssetError(AssetErrorUpstream, "China Mobile asset group creation returned an empty group ID", http.StatusBadGateway) | |||
| return "", newAssetError(AssetErrorUpstream, "asset group creation returned an empty group ID", http.StatusBadGateway) | |||
| } | |||
| if err := model.CreateUserAssetGroup(userId, channel.Id, groupId); err == nil { | |||
| return groupId, nil | |||
| @@ -52,7 +58,7 @@ func GetOrCreateChinaMobileUserAssetGroup(ctx context.Context, userId int, chann | |||
| if readErr != nil { | |||
| return "", newAssetError(AssetErrorServer, readErr.Error(), http.StatusInternalServerError) | |||
| } | |||
| return "", newAssetError(AssetErrorServer, "failed to persist China Mobile user asset group", http.StatusInternalServerError) | |||
| return "", newAssetError(AssetErrorServer, "failed to persist user asset group", http.StatusInternalServerError) | |||
| } | |||
| func CreateChinaMobileUserAssetGroup(ctx context.Context, userId int, adapter AssetAdapter, channel *model.Channel) (string, *AssetError) { | |||
| @@ -82,7 +88,7 @@ func CreateChinaMobileUserAssetGroup(ctx context.Context, userId int, adapter As | |||
| return strings.TrimSpace(payload.Result.GroupId), nil | |||
| } | |||
| func ScopeChinaMobileAssetRequest(req *AssetRequest, groupId string) { | |||
| func ScopeManagedAssetRequest(req *AssetRequest, groupId string) { | |||
| switch req.Action.Operation { | |||
| case AssetOperationAssetCreate: | |||
| req.Body["GroupId"] = groupId | |||
| @@ -96,7 +102,7 @@ func ScopeChinaMobileAssetRequest(req *AssetRequest, groupId string) { | |||
| } | |||
| } | |||
| func RequireChinaMobileAssetOwnership(ctx context.Context, adapter AssetAdapter, channel *model.Channel, request AssetRequest, groupId string) *AssetError { | |||
| func RequireManagedAssetOwnership(ctx context.Context, adapter AssetAdapter, channel *model.Channel, request AssetRequest, groupId string) *AssetError { | |||
| spec, ok := ParseAssetAction("GetAsset") | |||
| if !ok { | |||
| return newAssetError(AssetErrorServer, "GetAsset action is not registered", http.StatusInternalServerError) | |||
| @@ -119,7 +125,7 @@ func RequireChinaMobileAssetOwnership(ctx context.Context, adapter AssetAdapter, | |||
| } `json:"Result"` | |||
| } | |||
| if err := common.Unmarshal(resp.Body, &payload); err != nil { | |||
| return newAssetError(AssetErrorUpstream, fmt.Sprintf("invalid China Mobile asset response: %v", err), http.StatusBadGateway) | |||
| return newAssetError(AssetErrorUpstream, fmt.Sprintf("invalid asset response: %v", err), http.StatusBadGateway) | |||
| } | |||
| if strings.TrimSpace(payload.Result.GroupId) != groupId { | |||
| return newAssetError(AssetErrorNotFound, "asset not found", http.StatusNotFound) | |||
| @@ -41,7 +41,7 @@ func chinaMobileAssetAction(t *testing.T, action string) AssetActionSpec { | |||
| return spec | |||
| } | |||
| func TestGetOrCreateChinaMobileUserAssetGroupCreatesThenReuses(t *testing.T) { | |||
| func TestGetOrCreateUserAssetGroupCreatesThenReuses(t *testing.T) { | |||
| setupChinaMobileUserAssetGroupDB(t) | |||
| channel := &model.Channel{Id: 101, Type: constant.ChannelTypeChinaMobileSeedance} | |||
| createCalls := 0 | |||
| @@ -50,17 +50,17 @@ func TestGetOrCreateChinaMobileUserAssetGroupCreatesThenReuses(t *testing.T) { | |||
| return "group-1", nil | |||
| } | |||
| groupID, assetErr := GetOrCreateChinaMobileUserAssetGroup(context.Background(), 10, channel, creator) | |||
| groupID, assetErr := GetOrCreateUserAssetGroup(context.Background(), 10, channel, creator) | |||
| require.Nil(t, assetErr) | |||
| assert.Equal(t, "group-1", groupID) | |||
| again, assetErr := GetOrCreateChinaMobileUserAssetGroup(context.Background(), 10, channel, creator) | |||
| again, assetErr := GetOrCreateUserAssetGroup(context.Background(), 10, channel, creator) | |||
| require.Nil(t, assetErr) | |||
| assert.Equal(t, "group-1", again) | |||
| assert.Equal(t, 1, createCalls) | |||
| } | |||
| func TestGetOrCreateChinaMobileUserAssetGroupConcurrentRequestsReuseBinding(t *testing.T) { | |||
| func TestGetOrCreateUserAssetGroupConcurrentRequestsReuseBinding(t *testing.T) { | |||
| setupChinaMobileUserAssetGroupDB(t) | |||
| channel := &model.Channel{Id: 102, Type: constant.ChannelTypeChinaMobileSeedance} | |||
| creator := func(context.Context, int, *model.Channel) (string, *AssetError) { | |||
| @@ -75,7 +75,7 @@ func TestGetOrCreateChinaMobileUserAssetGroupConcurrentRequestsReuseBinding(t *t | |||
| wg.Add(1) | |||
| go func() { | |||
| defer wg.Done() | |||
| groupID, assetErr := GetOrCreateChinaMobileUserAssetGroup(context.Background(), 10, channel, creator) | |||
| groupID, assetErr := GetOrCreateUserAssetGroup(context.Background(), 10, channel, creator) | |||
| results <- groupID | |||
| errs <- assetErr | |||
| }() | |||
| @@ -96,21 +96,21 @@ func TestGetOrCreateChinaMobileUserAssetGroupConcurrentRequestsReuseBinding(t *t | |||
| assert.Equal(t, "group-concurrent", binding.GroupId) | |||
| } | |||
| func TestScopeChinaMobileAssetRequestOverwritesClientGroup(t *testing.T) { | |||
| func TestScopeManagedAssetRequestOverwritesClientGroup(t *testing.T) { | |||
| create := AssetRequest{Action: chinaMobileAssetAction(t, "CreateAsset"), Body: map[string]any{"GroupId": "forged"}} | |||
| ScopeChinaMobileAssetRequest(&create, "owned") | |||
| ScopeManagedAssetRequest(&create, "owned") | |||
| assert.Equal(t, "owned", create.Body["GroupId"]) | |||
| list := AssetRequest{Action: chinaMobileAssetAction(t, "ListAssets"), Body: map[string]any{"Filter": map[string]any{"GroupIds": []any{"forged"}}}} | |||
| ScopeChinaMobileAssetRequest(&list, "owned") | |||
| ScopeManagedAssetRequest(&list, "owned") | |||
| assert.Equal(t, []string{"owned"}, list.Body["Filter"].(map[string]any)["GroupIds"]) | |||
| } | |||
| func TestRequireChinaMobileAssetOwnershipHidesMismatchedGroup(t *testing.T) { | |||
| func TestRequireManagedAssetOwnershipHidesMismatchedGroup(t *testing.T) { | |||
| adapter := &recordingAssetAdapter{getAssetGroupID: "other"} | |||
| request := AssetRequest{Action: chinaMobileAssetAction(t, "DeleteAsset"), Body: map[string]any{"Id": "asset-1"}} | |||
| assetErr := RequireChinaMobileAssetOwnership(context.Background(), adapter, &model.Channel{}, request, "owned") | |||
| assetErr := RequireManagedAssetOwnership(context.Background(), adapter, &model.Channel{}, request, "owned") | |||
| require.NotNil(t, assetErr) | |||
| assert.Equal(t, AssetErrorNotFound, assetErr.Type) | |||
| @@ -0,0 +1,53 @@ | |||
| package service | |||
| import ( | |||
| "context" | |||
| "fmt" | |||
| "net/http" | |||
| "strings" | |||
| "github.com/QuantumNous/new-api/common" | |||
| "github.com/QuantumNous/new-api/constant" | |||
| "github.com/QuantumNous/new-api/model" | |||
| ) | |||
| // IsDoubaoVideoAssetChannel reports whether the channel is an official | |||
| // Volcengine (DoubaoVideo) channel whose assets are managed by the platform. | |||
| func IsDoubaoVideoAssetChannel(channel *model.Channel) bool { | |||
| return channel != nil && channel.Type == constant.ChannelTypeDoubaoVideo | |||
| } | |||
| // CreateDoubaoVideoUserAssetGroup creates a per-user upstream asset group. | |||
| // The Ark API derives the group type internally; passing GroupType is | |||
| // rejected (InvalidParameter), so only the name is sent. The response | |||
| // carries the new group id in Result.Id (Result.GroupId is tolerated too). | |||
| func CreateDoubaoVideoUserAssetGroup(ctx context.Context, userId int, adapter AssetAdapter, channel *model.Channel) (string, *AssetError) { | |||
| spec, ok := ParseAssetAction("CreateAssetGroup") | |||
| if !ok { | |||
| return "", newAssetError(AssetErrorServer, "CreateAssetGroup action is not registered", http.StatusInternalServerError) | |||
| } | |||
| resp, assetErr := adapter.DoAssetRequest(ctx, channel, AssetRequest{ | |||
| Action: spec, | |||
| Version: "2024-01-01", | |||
| Body: map[string]any{ | |||
| "Name": fmt.Sprintf("new-api-user-%d-channel-%d", userId, channel.Id), | |||
| }, | |||
| }) | |||
| if assetErr != nil { | |||
| return "", assetErr | |||
| } | |||
| var payload struct { | |||
| Result struct { | |||
| Id string `json:"Id"` | |||
| GroupId string `json:"GroupId"` | |||
| } `json:"Result"` | |||
| } | |||
| if err := common.Unmarshal(resp.Body, &payload); err != nil { | |||
| return "", newAssetError(AssetErrorUpstream, fmt.Sprintf("invalid Volcengine asset group response: %v", err), http.StatusBadGateway) | |||
| } | |||
| groupId := strings.TrimSpace(payload.Result.Id) | |||
| if groupId == "" { | |||
| groupId = strings.TrimSpace(payload.Result.GroupId) | |||
| } | |||
| return groupId, nil | |||
| } | |||
| @@ -25,7 +25,11 @@ func VideoAssetFamilies() []VideoAssetFamily { | |||
| func VideoAssetChannelTypesForFamily(family VideoAssetFamily) []int { | |||
| switch family { | |||
| case VideoAssetFamilySeedance: | |||
| // DoubaoVideo (official Volcengine Ark) joins the family so a user's | |||
| // asset upload channel and video task channel stay the same; assets | |||
| // are referenced by asset:// ids passed through to the upstream. | |||
| return []int{ | |||
| constant.ChannelTypeDoubaoVideo, | |||
| constant.ChannelTypeDoubaoVideoCompatibleAiping, | |||
| constant.ChannelTypeDoubaoVideoCompatibleTianyiYun, | |||
| constant.ChannelTypeChinaMobileSeedance, | |||
| @@ -218,6 +222,12 @@ func IsUsableVideoAssetChannelForFamily(channel *model.Channel, tokenGroup strin | |||
| if !MatchDoubaoAssetGroup(channel.GetGroups(), tokenGroup) { | |||
| return false | |||
| } | |||
| // Official Volcengine channels additionally need the asset AK/SK | |||
| // credential before they can serve the asset family; without it they are | |||
| // skipped so binding/auto-match falls through to compatible channels. | |||
| if !assetCredentialConfigured(channel) { | |||
| return false | |||
| } | |||
| modelName = strings.TrimSpace(modelName) | |||
| if modelName == "" { | |||
| return true | |||
| @@ -1365,7 +1365,7 @@ const EditChannelModal = (props) => { | |||
| if (isEdit && (!localInputs.key || localInputs.key.trim() === '')) { | |||
| delete localInputs.key; | |||
| } | |||
| if (localInputs.type === 61) { | |||
| if ([54, 61].includes(localInputs.type)) { | |||
| const credential = localInputs.asset_credential || {}; | |||
| const accessKey = String(credential.access_key || '').trim(); | |||
| const secretKey = String(credential.secret_key || '').trim(); | |||
| @@ -2538,9 +2538,11 @@ const EditChannelModal = (props) => { | |||
| </> | |||
| )} | |||
| {inputs.type === 61 && ( | |||
| {[54, 61].includes(inputs.type) && ( | |||
| <Card className='mb-4' shadows='always'> | |||
| <Text strong>{t('移动云素材凭证')}</Text> | |||
| <Text strong> | |||
| {t(inputs.type === 61 ? '移动云素材凭证' : '火山素材凭证')} | |||
| </Text> | |||
| <Text type='tertiary' size='small' className='block mb-3'> | |||
| {inputs.asset_credential_configured | |||
| ? t('已配置素材凭证;留空不会覆盖现有凭证。') | |||
| @@ -2558,12 +2560,21 @@ const EditChannelModal = (props) => { | |||
| mode='password' | |||
| autoComplete='new-password' | |||
| /> | |||
| <Form.Input | |||
| field='asset_credential.pool_id' | |||
| label='PoolID' | |||
| placeholder='CIDC-CORE-00' | |||
| extraText={t('留空使用默认 PoolID:CIDC-CORE-00')} | |||
| /> | |||
| {inputs.type === 61 ? ( | |||
| <Form.Input | |||
| field='asset_credential.pool_id' | |||
| label='PoolID' | |||
| placeholder='CIDC-CORE-00' | |||
| extraText={t('留空使用默认 PoolID:CIDC-CORE-00')} | |||
| /> | |||
| ) : ( | |||
| <Form.Input | |||
| field='asset_credential.pool_id' | |||
| label={t('项目编码 ProjectCode')} | |||
| placeholder='bTrHbDj6TB6ZhF6O' | |||
| extraText={t('火山项目编码,可选')} | |||
| /> | |||
| )} | |||
| </Card> | |||
| )} | |||