1036 lines
39 KiB
Go
1036 lines
39 KiB
Go
package app
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"net/http"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
|
||
"infinite-canvas/backend/internal/model"
|
||
"infinite-canvas/backend/internal/protocol"
|
||
"infinite-canvas/backend/internal/repository"
|
||
|
||
"gorm.io/gorm"
|
||
)
|
||
|
||
type ChannelModelRequest struct {
|
||
ModelKey string `json:"modelKey"`
|
||
ProviderModelKey string `json:"providerModelKey"`
|
||
DisplayName string `json:"displayName"`
|
||
ChannelLabel string `json:"channelLabel"`
|
||
Tags []model.ChannelModelTag `json:"tags"`
|
||
Description string `json:"description"`
|
||
Icon string `json:"icon"`
|
||
Capability string `json:"capability"`
|
||
Protocol string `json:"protocol"`
|
||
BillingMode string `json:"billingMode"`
|
||
UnitPriceMicrocredits int64 `json:"unitPriceMicrocredits"`
|
||
InputTokenPriceMicrocredits int64 `json:"inputTokenPriceMicrocredits"`
|
||
OutputTokenPriceMicrocredits int64 `json:"outputTokenPriceMicrocredits"`
|
||
CachedTokenPriceMicrocredits int64 `json:"cachedTokenPriceMicrocredits"`
|
||
PriceConfigured bool `json:"priceConfigured"`
|
||
Enabled *bool `json:"enabled"`
|
||
CapabilityConfig *ModelCapabilityConfig `json:"capabilityConfig"`
|
||
PriceTiers []ChannelModelPriceTierRequest `json:"priceTiers"`
|
||
}
|
||
|
||
const maxAdminChannelModelBatchDeleteCount = 100
|
||
|
||
// ChannelModelPriceTierRequest 是系统渠道内某个规格的上游 SKU 与结算价格。
|
||
// Resolution="*"、VideoSeconds=0 分别表示任意分辨率和任意时长。
|
||
type ChannelModelPriceTierRequest struct {
|
||
CostPricing model.CreditCostPricing `json:"costPricing"`
|
||
// Selector 是 SKU 的规范匹配条件。支持 operation、quality、size、vquality、videoSeconds、imageCount、videoGenerateAudio;
|
||
// operation 可区分文生/图生/视频生,避免同一分辨率下错误复用价格。
|
||
Selector map[string]string `json:"selector"`
|
||
Resolution string `json:"resolution"`
|
||
VideoSeconds int `json:"videoSeconds"`
|
||
ProviderModelKey string `json:"providerModelKey"`
|
||
BillingMode string `json:"billingMode"`
|
||
UnitPriceMicrocredits int64 `json:"unitPriceMicrocredits"`
|
||
InputTokenPriceMicrocredits int64 `json:"inputTokenPriceMicrocredits"`
|
||
OutputTokenPriceMicrocredits int64 `json:"outputTokenPriceMicrocredits"`
|
||
CachedTokenPriceMicrocredits int64 `json:"cachedTokenPriceMicrocredits"`
|
||
PriceConfigured bool `json:"priceConfigured"`
|
||
Enabled *bool `json:"enabled"`
|
||
}
|
||
|
||
// AdminChannelModelFetchResult 是管理员从上游拉目录后的汇总:models 为去重后的标识,added 为本次新建条数。
|
||
type AdminChannelModelFetchResult struct {
|
||
Models []string `json:"models"`
|
||
Added int64 `json:"added"`
|
||
}
|
||
|
||
type AdminChannelModelImportRequest struct {
|
||
Models []string `json:"models"`
|
||
}
|
||
|
||
type AdminChannelModelTestResult struct {
|
||
DurationMs int64 `json:"durationMs"`
|
||
}
|
||
|
||
func (s *Service) EnsureSystemChannelModels() error {
|
||
channels, err := s.repo.SystemChannels(true)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
for index := range channels {
|
||
items, err := s.repo.ChannelModels(channels[index].ID, true)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if len(items) == 0 {
|
||
if err := s.syncInitialChannelModels(&channels[index], channelModelNames(channels[index])); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (s *Service) AdminChannelModels(actor *model.User, channelID string) ([]model.ChannelModel, error) {
|
||
if err := s.RequireAdmin(actor); err != nil {
|
||
return nil, err
|
||
}
|
||
if _, err := s.adminSystemChannel(channelID); err != nil {
|
||
return nil, err
|
||
}
|
||
items, err := s.ensureChannelModels(channelID, true)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
for index := range items {
|
||
if strings.TrimSpace(items[index].CapabilityConfigJSON) == "" {
|
||
continue
|
||
}
|
||
config, decodeErr := DecodeModelCapabilityConfig(items[index].CapabilityConfigJSON)
|
||
if decodeErr != nil || config == nil {
|
||
continue
|
||
}
|
||
normalized, normalizeErr := NormalizeModelCapabilityConfigForModel(items[index].Capability, string(items[index].Protocol), firstNonEmpty(items[index].ProviderModelKey, items[index].ModelKey), config)
|
||
if normalizeErr != nil || normalized == nil {
|
||
continue
|
||
}
|
||
encoded, encodeErr := json.Marshal(normalized)
|
||
var value map[string]any
|
||
if encodeErr == nil && json.Unmarshal(encoded, &value) == nil {
|
||
items[index].CapabilityConfig = value
|
||
}
|
||
}
|
||
return items, nil
|
||
}
|
||
|
||
func (s *Service) SystemChannelModel(channelID string, modelKey string) (*model.ChannelModel, error) {
|
||
return s.repo.ChannelModelByKey(channelID, strings.TrimPrefix(strings.TrimSpace(modelKey), "models/"))
|
||
}
|
||
|
||
// SystemChannelHasProtocol 用于没有携带 model 字段的轮询请求:先确认渠道确实配置了该协议,
|
||
// 再由 handler 按协议限定请求路径,避免用空模型绕过系统渠道授权。
|
||
func (s *Service) SystemChannelHasProtocol(channelID string, protocol model.ChannelInterfaceType) (bool, error) {
|
||
items, err := s.repo.ChannelModels(channelID, false)
|
||
if err != nil {
|
||
return false, err
|
||
}
|
||
for _, item := range items {
|
||
if item.Protocol == protocol {
|
||
return true, nil
|
||
}
|
||
}
|
||
return false, nil
|
||
}
|
||
|
||
func (s *Service) FetchAdminChannelModels(ctx context.Context, actor *model.User, channelID string) (*AdminChannelModelFetchResult, error) {
|
||
if err := s.RequireAdmin(actor); err != nil {
|
||
return nil, err
|
||
}
|
||
channel, err := s.adminSystemChannel(channelID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
headers, err := ParseOutboundHeadersJSON(channel.HeadersJSON)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
// 使用服务端保存的渠道密钥和请求头访问上游,避免敏感配置再次经过浏览器。
|
||
models, err := s.FetchChannelModels(ctx, actor, ChannelModelsRequest{BaseURL: channel.BaseURL, APIKey: channel.APIKey, APIFormat: channel.APIFormat, Headers: headers})
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
// 只按当前未删除记录去重;普通手动删除的模型仍可重新拉取,已合并进模型家族的 SKU 除外。
|
||
existing, err := s.repo.ChannelModels(channelID, true)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
known := make(map[string]struct{}, len(existing))
|
||
for _, item := range existing {
|
||
known[channelModelCatalogKey(item.ModelKey)] = struct{}{}
|
||
}
|
||
retired := retiredChannelModelKeys(channel.RetiredModelsJSON)
|
||
missing := make([]model.ChannelModel, 0, len(models))
|
||
for _, name := range models {
|
||
name = strings.TrimPrefix(strings.TrimSpace(name), "models/")
|
||
key := channelModelCatalogKey(name)
|
||
if _, ok := known[key]; ok || retired[key] {
|
||
continue
|
||
}
|
||
// 自动发现不能绕过定价边界;新模型由管理员定价后再手动启用。
|
||
modelID, idErr := s.repo.NextPrefixedID("MODEL")
|
||
if idErr != nil {
|
||
return nil, idErr
|
||
}
|
||
missing = append(missing, model.ChannelModel{ID: modelID, ChannelID: channelID, ModelKey: name, ProviderModelKey: name, DisplayName: name, BillingMode: "fixed_request", Enabled: false, PriceVersion: 1})
|
||
}
|
||
added, err := s.repo.CreateMissingChannelModels(missing)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if added > 0 {
|
||
s.invalidateRouteCatalog()
|
||
}
|
||
return &AdminChannelModelFetchResult{Models: models, Added: added}, nil
|
||
}
|
||
|
||
// PreviewAdminChannelModels 只读取上游模型目录,不修改渠道模型配置。
|
||
func (s *Service) PreviewAdminChannelModels(ctx context.Context, actor *model.User, channelID string) ([]string, error) {
|
||
if err := s.RequireAdmin(actor); err != nil {
|
||
return nil, err
|
||
}
|
||
return s.fetchAdminChannelModelCatalog(ctx, actor, channelID)
|
||
}
|
||
|
||
// ImportAdminChannelModels 只导入管理员明确选择、且仍存在于上游目录中的模型。
|
||
func (s *Service) ImportAdminChannelModels(ctx context.Context, actor *model.User, channelID string, selected []string) (*AdminChannelModelFetchResult, error) {
|
||
if err := s.RequireAdmin(actor); err != nil {
|
||
return nil, err
|
||
}
|
||
channel, err := s.adminSystemChannel(channelID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
models, err := s.fetchAdminChannelModelCatalog(ctx, actor, channelID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if len(selected) == 0 {
|
||
return nil, BadAuthRequest("请至少选择一个要导入的模型")
|
||
}
|
||
if len(selected) > 500 {
|
||
return nil, BadAuthRequest("单次最多导入 500 个模型")
|
||
}
|
||
available := make(map[string]string, len(models))
|
||
for _, name := range models {
|
||
available[channelModelCatalogKey(name)] = name
|
||
}
|
||
chosen := make([]string, 0, len(selected))
|
||
seen := make(map[string]struct{}, len(selected))
|
||
for _, rawName := range selected {
|
||
name := strings.TrimPrefix(strings.TrimSpace(rawName), "models/")
|
||
key := channelModelCatalogKey(name)
|
||
if key == "" {
|
||
continue
|
||
}
|
||
canonical, ok := available[key]
|
||
if !ok {
|
||
return nil, BadAuthRequest("所选模型不在上游模型目录中:" + name)
|
||
}
|
||
if _, ok := seen[key]; ok {
|
||
continue
|
||
}
|
||
seen[key] = struct{}{}
|
||
chosen = append(chosen, canonical)
|
||
}
|
||
if len(chosen) == 0 {
|
||
return nil, BadAuthRequest("请至少选择一个有效的模型")
|
||
}
|
||
|
||
existing, err := s.repo.ChannelModels(channelID, true)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
known := make(map[string]struct{}, len(existing))
|
||
for _, item := range existing {
|
||
providerKey := firstNonEmpty(item.ProviderModelKey, item.ModelKey)
|
||
known[channelModelCatalogKey(providerKey)] = struct{}{}
|
||
}
|
||
retired := retiredChannelModelKeys(channel.RetiredModelsJSON)
|
||
missing := make([]model.ChannelModel, 0, len(chosen))
|
||
for _, name := range chosen {
|
||
key := channelModelCatalogKey(name)
|
||
if _, ok := known[key]; ok || retired[key] {
|
||
continue
|
||
}
|
||
modelID, idErr := s.repo.NextPrefixedID("MODEL")
|
||
if idErr != nil {
|
||
return nil, idErr
|
||
}
|
||
missing = append(missing, model.ChannelModel{ID: modelID, ChannelID: channelID, ModelKey: name, DisplayName: name, BillingMode: "fixed_request", Enabled: false, PriceConfigured: false, PriceVersion: 1})
|
||
known[key] = struct{}{}
|
||
}
|
||
added, err := s.repo.CreateMissingChannelModels(missing)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if added > 0 {
|
||
s.invalidateRouteCatalog()
|
||
}
|
||
return &AdminChannelModelFetchResult{Models: chosen, Added: added}, nil
|
||
}
|
||
|
||
func (s *Service) fetchAdminChannelModelCatalog(ctx context.Context, actor *model.User, channelID string) ([]string, error) {
|
||
channel, err := s.adminSystemChannel(channelID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
headers, err := ParseOutboundHeadersJSON(channel.HeadersJSON)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
models, err := s.FetchChannelModels(ctx, actor, ChannelModelsRequest{BaseURL: channel.BaseURL, APIKey: channel.APIKey, APIFormat: channel.APIFormat, Headers: headers})
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return uniqueNonEmpty(models), nil
|
||
}
|
||
|
||
func (s *Service) SaveAdminChannelModel(actor *model.User, channelID string, id string, req ChannelModelRequest) (*model.ChannelModel, error) {
|
||
if err := s.RequireAdmin(actor); err != nil {
|
||
return nil, err
|
||
}
|
||
channelLabel := strings.TrimSpace(req.ChannelLabel)
|
||
tags, err := normalizeChannelModelTags(req.Tags)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
description := strings.TrimSpace(req.Description)
|
||
if len([]rune(description)) > 500 {
|
||
return nil, BadAuthRequest("模型描述不能超过 500 字")
|
||
}
|
||
if len([]rune(channelLabel)) > 80 {
|
||
return nil, BadAuthRequest("渠道展示名不能超过 80 字")
|
||
}
|
||
channel, err := s.repo.AdminSystemChannel(channelID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
modelKey, providerModelKey, capability, protocol, err := s.normalizeChannelModelContract(channel, req)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
// 先检查同渠道重复模型,避免无关能力校验或生成无用序列号掩盖真正的冲突。
|
||
conflict, conflictErr := s.repo.ChannelModelByKeyIncludingDisabled(channelID, modelKey)
|
||
if conflictErr != nil && !errors.Is(conflictErr, gorm.ErrRecordNotFound) {
|
||
return nil, conflictErr
|
||
}
|
||
if conflict != nil && conflict.ID != strings.TrimSpace(id) {
|
||
return nil, BadAuthRequest("该渠道已存在模型 " + modelKey + ",请直接编辑已有模型")
|
||
}
|
||
if capability == "text" || capability == "image" || capability == "video" {
|
||
if _, err := NormalizeModelCapabilityConfigForModel(capability, string(protocol), providerModelKey, req.CapabilityConfig); err != nil {
|
||
return nil, err
|
||
}
|
||
}
|
||
tiers, err := s.normalizeChannelModelPriceTiers(req, capability, protocol, providerModelKey)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
modelID, err := s.repo.NextPrefixedID("MODEL")
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
item := &model.ChannelModel{ID: modelID, ChannelID: channelID, Enabled: true, PriceVersion: 1}
|
||
previousProviderModelKey := ""
|
||
if id != "" {
|
||
item, err = s.repo.ChannelModelByID(channelID, id)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
previousProviderModelKey = strings.TrimPrefix(strings.TrimSpace(item.ProviderModelKey), "models/")
|
||
item.PriceVersion++
|
||
}
|
||
// 模型级上游键重命名时,与旧值相同的档位键属于“跟随模型默认”的隐式固化,必须级联跟随;
|
||
// 否则任务请求会继续把旧上游键发给供应商。管理员显式配置的其他上游 SKU 不受影响。
|
||
if previousProviderModelKey != "" && providerModelKey != previousProviderModelKey {
|
||
for index := range tiers {
|
||
if strings.TrimPrefix(strings.TrimSpace(tiers[index].ProviderModelKey), "models/") == previousProviderModelKey {
|
||
tiers[index].ProviderModelKey = providerModelKey
|
||
}
|
||
}
|
||
}
|
||
item.ModelKey = modelKey
|
||
item.ProviderModelKey = providerModelKey
|
||
item.DisplayName = strings.TrimSpace(req.DisplayName)
|
||
item.ChannelLabel = channelLabel
|
||
item.Tags = tags
|
||
item.Description = description
|
||
if item.DisplayName == "" {
|
||
item.DisplayName = modelKey
|
||
}
|
||
item.Icon = strings.TrimSpace(req.Icon)
|
||
item.Capability = capability
|
||
item.Protocol = protocol
|
||
s.applyChannelModelPriceTierSummary(item, tiers)
|
||
if capability == "text" || capability == "image" || capability == "video" {
|
||
capabilityConfig, normalizeErr := NormalizeModelCapabilityConfigForModel(capability, string(protocol), providerModelKey, req.CapabilityConfig)
|
||
if normalizeErr != nil {
|
||
return nil, normalizeErr
|
||
}
|
||
encoded, encodeErr := json.Marshal(capabilityConfig)
|
||
if encodeErr != nil {
|
||
return nil, encodeErr
|
||
}
|
||
if item.CapabilityConfigJSON != string(encoded) {
|
||
item.CapabilityVersion++
|
||
}
|
||
item.CapabilityConfigJSON = string(encoded)
|
||
} else {
|
||
item.CapabilityConfigJSON = ""
|
||
item.CapabilityVersion = 0
|
||
}
|
||
if req.Enabled != nil {
|
||
item.Enabled = *req.Enabled
|
||
}
|
||
if err := validateChannelModelTierCapabilities(tiers, item.CapabilityConfigJSON, capability); err != nil {
|
||
return nil, err
|
||
}
|
||
// 渠道模型及所有价格档必须同时落库,不能出现“能力已开放但规格价格尚未更新”的窗口。
|
||
if err := s.repo.SaveChannelModelWithPriceTiers(item, tiers); err != nil {
|
||
return nil, err
|
||
}
|
||
item.PriceTiers = tiers
|
||
s.invalidateRouteCatalog()
|
||
if err := s.syncChannelModelNames(channel); err != nil {
|
||
return nil, err
|
||
}
|
||
if err := s.syncLogicalModelsFromChannelModel(actor, item); err != nil {
|
||
return nil, err
|
||
}
|
||
return item, nil
|
||
}
|
||
|
||
func validateChannelModelTierCapabilities(tiers []model.ChannelModelPriceTier, rawCapabilityConfig string, capability string) error {
|
||
if capability != "video" {
|
||
return nil
|
||
}
|
||
config, err := DecodeModelCapabilityConfig(rawCapabilityConfig)
|
||
if err != nil || config == nil || config.Video == nil {
|
||
return BadAuthRequest("视频模型能力配置无效,无法校验价格档规格")
|
||
}
|
||
resolutionSupported := make(map[string]bool, len(config.Video.Resolutions))
|
||
for _, resolution := range config.Video.Resolutions {
|
||
resolutionSupported[normalizeChannelModelTierResolution(resolution)] = true
|
||
}
|
||
durationSupported := make(map[int]bool, len(config.Video.Duration.Values))
|
||
for _, seconds := range config.Video.Duration.Values {
|
||
durationSupported[seconds] = true
|
||
}
|
||
for _, tier := range tiers {
|
||
if tier.Resolution != "*" && !resolutionSupported[normalizeChannelModelTierResolution(tier.Resolution)] {
|
||
return BadAuthRequest("价格档分辨率不在该视频模型支持范围内:" + tier.Resolution)
|
||
}
|
||
if tier.VideoSeconds == 0 {
|
||
continue
|
||
}
|
||
if !videoDurationSupported(config.Video) {
|
||
continue
|
||
}
|
||
if config.Video.Duration.Selection == "enum" && !durationSupported[tier.VideoSeconds] {
|
||
return BadAuthRequest(fmt.Sprintf("价格档时长 %d 秒不在该视频模型支持范围内", tier.VideoSeconds))
|
||
}
|
||
if config.Video.Duration.Selection == "range" && (tier.VideoSeconds < config.Video.Duration.Min || tier.VideoSeconds > config.Video.Duration.Max || (config.Video.Duration.Step > 0 && (tier.VideoSeconds-config.Video.Duration.Min)%config.Video.Duration.Step != 0)) {
|
||
return BadAuthRequest(fmt.Sprintf("价格档时长 %d 秒不在该视频模型支持范围内", tier.VideoSeconds))
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// syncLogicalModelsFromChannelModel 只失效路由目录。系统渠道 SKU 与前台模型目录
|
||
// 分别维护:保存渠道模型绝不能自动创建、覆盖或删除前台模型及其线路配置。
|
||
func (s *Service) syncLogicalModelsFromChannelModel(actor *model.User, channelModel *model.ChannelModel) error {
|
||
_ = actor
|
||
_ = channelModel
|
||
s.invalidateRouteCatalog()
|
||
return nil
|
||
}
|
||
|
||
func supportsTokenBilling(capability string, _ model.ChannelInterfaceType) bool {
|
||
return capability == "text" || capability == "video"
|
||
}
|
||
|
||
func (s *Service) normalizeChannelModelPriceTiers(req ChannelModelRequest, capability string, protocol model.ChannelInterfaceType, fallbackProviderModelKey string) ([]model.ChannelModelPriceTier, error) {
|
||
inputs := req.PriceTiers
|
||
// 兼容旧管理 API:没有 priceTiers 的请求等价于一个默认价格档。
|
||
if len(inputs) == 0 {
|
||
enabled := true
|
||
inputs = []ChannelModelPriceTierRequest{{
|
||
Resolution: "*", VideoSeconds: 0, ProviderModelKey: fallbackProviderModelKey,
|
||
BillingMode: req.BillingMode, UnitPriceMicrocredits: req.UnitPriceMicrocredits,
|
||
InputTokenPriceMicrocredits: req.InputTokenPriceMicrocredits, OutputTokenPriceMicrocredits: req.OutputTokenPriceMicrocredits,
|
||
CachedTokenPriceMicrocredits: req.CachedTokenPriceMicrocredits, PriceConfigured: req.PriceConfigured, Enabled: &enabled,
|
||
}}
|
||
}
|
||
result := make([]model.ChannelModelPriceTier, 0, len(inputs))
|
||
seen := make(map[string]bool, len(inputs))
|
||
for _, input := range inputs {
|
||
selector, resolution, videoSeconds, selectorErr := normalizeChannelModelTierSelector(capability, input)
|
||
if selectorErr != nil {
|
||
return nil, selectorErr
|
||
}
|
||
_, key, keyErr := model.CanonicalSKUSelector(selector)
|
||
if keyErr != nil {
|
||
return nil, keyErr
|
||
}
|
||
if seen[key] {
|
||
return nil, BadAuthRequest("同一个操作和规格组合只能配置一个价格档")
|
||
}
|
||
seen[key] = true
|
||
billingMode := strings.TrimSpace(input.BillingMode)
|
||
if billingMode == "" {
|
||
billingMode = "fixed_request"
|
||
}
|
||
if err := validateChannelModelTierPricing(capability, protocol, billingMode, input); err != nil {
|
||
return nil, err
|
||
}
|
||
id, idErr := s.repo.NextPrefixedID("PTIER")
|
||
if idErr != nil {
|
||
return nil, idErr
|
||
}
|
||
enabled := input.Enabled == nil || *input.Enabled
|
||
result = append(result, model.ChannelModelPriceTier{
|
||
CostPricing: input.CostPricing,
|
||
ID: id,
|
||
SelectorKey: key,
|
||
SelectorJSON: key,
|
||
Selector: selector,
|
||
Resolution: resolution,
|
||
VideoSeconds: videoSeconds,
|
||
ProviderModelKey: strings.TrimPrefix(strings.TrimSpace(firstNonEmpty(input.ProviderModelKey, fallbackProviderModelKey)), "models/"),
|
||
BillingMode: billingMode,
|
||
UnitPriceMicrocredits: input.UnitPriceMicrocredits,
|
||
InputTokenPriceMicrocredits: input.InputTokenPriceMicrocredits,
|
||
OutputTokenPriceMicrocredits: input.OutputTokenPriceMicrocredits,
|
||
CachedTokenPriceMicrocredits: input.CachedTokenPriceMicrocredits,
|
||
PriceConfigured: input.PriceConfigured,
|
||
Enabled: enabled,
|
||
PriceVersion: 1,
|
||
})
|
||
}
|
||
return result, nil
|
||
}
|
||
|
||
func normalizeChannelModelTierSelector(capability string, input ChannelModelPriceTierRequest) (map[string]string, string, int, error) {
|
||
selector := make(map[string]string, len(input.Selector)+3)
|
||
for rawKey, rawValue := range input.Selector {
|
||
key := strings.TrimSpace(rawKey)
|
||
value := strings.TrimSpace(rawValue)
|
||
if key == "" || value == "" {
|
||
continue
|
||
}
|
||
switch key {
|
||
case "operation":
|
||
value = strings.ToLower(value)
|
||
case "quality", "size":
|
||
value = strings.ToLower(value)
|
||
if value == "auto" || value == "any" {
|
||
value = "*"
|
||
}
|
||
case "vquality":
|
||
value = normalizeChannelModelTierResolution(value)
|
||
case "videoSeconds":
|
||
seconds, err := strconv.Atoi(value)
|
||
if err != nil || seconds < 0 {
|
||
return nil, "", 0, BadAuthRequest("视频价格档时长必须是非负整数")
|
||
}
|
||
if seconds == 0 {
|
||
continue
|
||
}
|
||
value = strconv.Itoa(seconds)
|
||
case "imageCount":
|
||
count, err := strconv.Atoi(value)
|
||
if err != nil || count < 0 {
|
||
return nil, "", 0, BadAuthRequest("参考图片数量必须是非负整数")
|
||
}
|
||
if count == 0 {
|
||
continue
|
||
}
|
||
value = strconv.Itoa(count)
|
||
case "videoGenerateAudio":
|
||
if capability != "video" {
|
||
return nil, "", 0, BadAuthRequest("只有视频模型可以按是否生成音频配置价格档")
|
||
}
|
||
value = strings.ToLower(value)
|
||
if value == "*" {
|
||
continue
|
||
}
|
||
if value != "true" && value != "false" {
|
||
return nil, "", 0, BadAuthRequest("视频价格档音频条件必须为 true、false 或 *")
|
||
}
|
||
default:
|
||
return nil, "", 0, BadAuthRequest("价格档不支持规格字段:" + key)
|
||
}
|
||
selector[key] = value
|
||
}
|
||
if capability == "video" {
|
||
if _, exists := selector["vquality"]; !exists {
|
||
if resolution := normalizeChannelModelTierResolution(input.Resolution); resolution != "*" {
|
||
selector["vquality"] = resolution
|
||
}
|
||
}
|
||
if _, exists := selector["videoSeconds"]; !exists && input.VideoSeconds > 0 {
|
||
selector["videoSeconds"] = strconv.Itoa(input.VideoSeconds)
|
||
}
|
||
} else if input.Resolution != "" && normalizeChannelModelTierResolution(input.Resolution) != "*" {
|
||
return nil, "", 0, BadAuthRequest("非视频模型不能使用视频分辨率价格档")
|
||
} else if input.VideoSeconds != 0 {
|
||
return nil, "", 0, BadAuthRequest("非视频模型不能使用视频时长价格档")
|
||
}
|
||
for _, key := range []string{"quality", "size"} {
|
||
if _, exists := selector[key]; exists && capability != "image" {
|
||
return nil, "", 0, BadAuthRequest("只有图片模型可以按 " + key + " 配置价格档")
|
||
}
|
||
}
|
||
if _, exists := selector["vquality"]; exists && capability != "video" {
|
||
return nil, "", 0, BadAuthRequest("只有视频模型可以按分辨率配置价格档")
|
||
}
|
||
if _, exists := selector["videoSeconds"]; exists && capability != "video" {
|
||
return nil, "", 0, BadAuthRequest("只有视频模型可以按时长配置价格档")
|
||
}
|
||
if _, exists := selector["imageCount"]; exists && capability != "video" {
|
||
return nil, "", 0, BadAuthRequest("只有视频模型可以按参考图片数量配置价格档")
|
||
}
|
||
resolution := "*"
|
||
if value := selector["vquality"]; value != "" {
|
||
resolution = value
|
||
}
|
||
videoSeconds := 0
|
||
if value := selector["videoSeconds"]; value != "" {
|
||
videoSeconds, _ = strconv.Atoi(value)
|
||
}
|
||
return selector, resolution, videoSeconds, nil
|
||
}
|
||
|
||
func validateChannelModelTierPricing(capability string, protocol model.ChannelInterfaceType, billingMode string, input ChannelModelPriceTierRequest) error {
|
||
if err := validateCreditCostPricing(capability, billingMode, input.CostPricing); err != nil {
|
||
return err
|
||
}
|
||
if billingMode != "fixed_request" && billingMode != "per_second" && billingMode != "token" {
|
||
return BadAuthRequest("模型计费方式仅支持按次、按秒或 Token")
|
||
}
|
||
if billingMode == "per_second" && capability != "video" {
|
||
return BadAuthRequest("只有视频模型可以按秒计费")
|
||
}
|
||
if billingMode == "token" && !supportsTokenBilling(capability, protocol) {
|
||
return BadAuthRequest("Token 计费仅支持文本和视频模型")
|
||
}
|
||
if input.UnitPriceMicrocredits < 0 || input.InputTokenPriceMicrocredits < 0 || input.OutputTokenPriceMicrocredits < 0 || input.CachedTokenPriceMicrocredits < 0 {
|
||
return BadAuthRequest("模型积分价格不能小于 0")
|
||
}
|
||
if billingMode == "token" {
|
||
return validateTokenPrices(capability, input.InputTokenPriceMicrocredits, input.OutputTokenPriceMicrocredits, input.CachedTokenPriceMicrocredits)
|
||
}
|
||
return validateTokenPrices("", input.InputTokenPriceMicrocredits, input.OutputTokenPriceMicrocredits, input.CachedTokenPriceMicrocredits)
|
||
}
|
||
|
||
func normalizeChannelModelTierResolution(raw string) string {
|
||
value := strings.TrimSpace(raw)
|
||
if value == "" || value == "*" || strings.EqualFold(value, "any") {
|
||
return "*"
|
||
}
|
||
normalized := normalizeModelRequestOption("vquality", value)
|
||
return strings.ToLower(strings.TrimSpace(fmt.Sprint(normalized)))
|
||
}
|
||
|
||
func (s *Service) applyChannelModelPriceTierSummary(item *model.ChannelModel, tiers []model.ChannelModelPriceTier) {
|
||
item.PriceConfigured = false
|
||
item.BillingMode = "fixed_request"
|
||
item.UnitPriceMicrocredits = 0
|
||
item.InputTokenPriceMicrocredits = 0
|
||
item.OutputTokenPriceMicrocredits = 0
|
||
item.CachedTokenPriceMicrocredits = 0
|
||
var summary *model.ChannelModelPriceTier
|
||
for index := range tiers {
|
||
tier := &tiers[index]
|
||
if tier.Enabled && tier.PriceConfigured {
|
||
item.PriceConfigured = true
|
||
}
|
||
if summary == nil || (tier.Resolution == "*" && tier.VideoSeconds == 0) {
|
||
summary = tier
|
||
}
|
||
}
|
||
if summary == nil {
|
||
return
|
||
}
|
||
item.BillingMode = summary.BillingMode
|
||
item.UnitPriceMicrocredits = summary.UnitPriceMicrocredits
|
||
item.InputTokenPriceMicrocredits = summary.InputTokenPriceMicrocredits
|
||
item.OutputTokenPriceMicrocredits = summary.OutputTokenPriceMicrocredits
|
||
item.CachedTokenPriceMicrocredits = summary.CachedTokenPriceMicrocredits
|
||
}
|
||
|
||
func (s *Service) TestAdminChannelModel(ctx context.Context, actor *model.User, channelID string, req ChannelModelRequest) (*AdminChannelModelTestResult, error) {
|
||
if err := s.RequireAdmin(actor); err != nil {
|
||
return nil, err
|
||
}
|
||
channel, err := s.adminSystemChannel(channelID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
modelKey, providerModelKey, capability, protocol, err := s.normalizeChannelModelContract(channel, req)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if capability == "text" || capability == "image" || capability == "video" {
|
||
if _, err := NormalizeModelCapabilityConfigForModel(capability, string(protocol), providerModelKey, req.CapabilityConfig); err != nil {
|
||
return nil, err
|
||
}
|
||
}
|
||
if strings.TrimSpace(channel.BaseURL) == "" || strings.TrimSpace(channel.APIKey) == "" {
|
||
return nil, BadAuthRequest("请先在渠道中配置 Base URL 和 API Key")
|
||
}
|
||
if _, err := ValidateOutboundURL(channel.BaseURL); err != nil {
|
||
return nil, err
|
||
}
|
||
headers, err := ParseOutboundHeadersJSON(channel.HeadersJSON)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
prompt := map[string]string{
|
||
"text": "Reply with OK.",
|
||
"image": "A simple gray circle on a white background.",
|
||
"video": "A static gray circle on a white background.",
|
||
"audio": "Model test.",
|
||
}[capability]
|
||
videoSeconds := "6"
|
||
videoSecondsValue := 6
|
||
if protocol == model.ChannelInterfaceVolcengineJiMengVideo {
|
||
videoSeconds = "5"
|
||
videoSecondsValue = 5
|
||
}
|
||
imageSize, imageQuality := "", ""
|
||
var imageProfile *ImageCapabilityConfig
|
||
videoRatio, videoResolution := videoTestDefaults(nil)
|
||
var videoProfile *VideoCapabilityConfig
|
||
switch capability {
|
||
case "image":
|
||
profile, normalizeErr := NormalizeModelCapabilityConfigForModel(capability, string(protocol), providerModelKey, req.CapabilityConfig)
|
||
if normalizeErr != nil {
|
||
return nil, normalizeErr
|
||
}
|
||
imageProfile = profile.Image
|
||
imageSize, imageQuality = imageTestDefaults(imageProfile)
|
||
case "video":
|
||
// 视频测试必须带上模型能力画像:声明式协议只按画像里的枚举回填分辨率名(如 480 -> 480p),
|
||
// 没有画像时会把裸数字发给上游,火山方舟等供应商会直接拒绝。
|
||
profile, normalizeErr := NormalizeModelCapabilityConfigForModel(capability, string(protocol), providerModelKey, req.CapabilityConfig)
|
||
if normalizeErr != nil {
|
||
return nil, normalizeErr
|
||
}
|
||
videoProfile = profile.Video
|
||
videoRatio, videoResolution = videoTestDefaults(videoProfile)
|
||
}
|
||
input := canvasGenerationInput{
|
||
Mode: capability,
|
||
Prompt: prompt,
|
||
Config: providerConfig{
|
||
ChannelID: channel.ID,
|
||
APIFormat: channel.APIFormat,
|
||
InterfaceType: string(protocol),
|
||
BaseURL: channel.BaseURL,
|
||
APIKey: channel.APIKey,
|
||
SecretKey: channel.SecretKey,
|
||
Headers: headers,
|
||
Model: providerModelKey,
|
||
ChannelModelKey: modelKey,
|
||
Size: map[string]string{"image": imageSize, "video": videoRatio}[capability],
|
||
Quality: imageQuality,
|
||
Count: "1",
|
||
VideoSeconds: videoSeconds,
|
||
VQuality: videoResolution,
|
||
VideoGenerateAudio: "false",
|
||
VideoWatermark: "false",
|
||
AudioVoice: "alloy",
|
||
AudioFormat: "mp3",
|
||
AudioSpeed: "1",
|
||
},
|
||
Metadata: map[string]interface{}{},
|
||
}
|
||
if capability == "image" {
|
||
input.ImageCapability = imageProfile
|
||
}
|
||
if capability == "video" {
|
||
input.VideoCapability = videoProfile
|
||
}
|
||
|
||
// 测试复用真实生成协议、运行时并发和熔断策略,但不创建用户任务或计费订单。
|
||
testCtx, cancel := context.WithTimeout(ctx, 10*time.Minute)
|
||
defer cancel()
|
||
testCtx = context.WithValue(testCtx, providerAnalyticsKey{}, providerAnalyticsContext{
|
||
Service: s, Billing: s.taskBilling(), UserID: actor.ID, ChannelID: channel.ID, Capability: capability,
|
||
Operation: "admin_model_test", Model: modelKey, VideoSeconds: videoSecondsValue,
|
||
})
|
||
testCtx = withProtocolRegistry(testCtx, s.protocolRegistry())
|
||
startedAt := time.Now()
|
||
switch capability {
|
||
case "text":
|
||
_, err = runTextTask(testCtx, input)
|
||
case "image":
|
||
_, err = runImageTask(testCtx, input)
|
||
case "video":
|
||
_, err = runVideoTask(testCtx, input)
|
||
case "audio":
|
||
_, err = runAudioTask(testCtx, input)
|
||
}
|
||
if err != nil {
|
||
status := http.StatusBadGateway
|
||
if errors.Is(err, context.DeadlineExceeded) {
|
||
status = http.StatusGatewayTimeout
|
||
}
|
||
return nil, WrapAppError(status, "模型测试失败:"+providerUserFacingErrorMessage(err), err)
|
||
}
|
||
return &AdminChannelModelTestResult{DurationMs: time.Since(startedAt).Milliseconds()}, nil
|
||
}
|
||
|
||
// 模型测试必须使用当前模型声明的默认参数,避免固定分辨率 SKU 被通用 1K 测试值误伤。
|
||
// videoTestDefaults 从模型能力画像取测试用的比例和分辨率;画像缺失时回退到最通用的 16:9 / 720。
|
||
func videoTestDefaults(profile *VideoCapabilityConfig) (string, string) {
|
||
if profile == nil {
|
||
return "16:9", "720"
|
||
}
|
||
ratio := strings.TrimSpace(profile.DefaultRatio)
|
||
if ratio == "" && len(profile.Ratios) > 0 {
|
||
ratio = strings.TrimSpace(profile.Ratios[0])
|
||
}
|
||
if ratio == "" {
|
||
ratio = "16:9"
|
||
}
|
||
resolution := strings.TrimSpace(profile.DefaultResolution)
|
||
if resolution == "" && len(profile.Resolutions) > 0 {
|
||
resolution = strings.TrimSpace(profile.Resolutions[0])
|
||
}
|
||
if resolution == "" {
|
||
resolution = "720"
|
||
}
|
||
return ratio, resolution
|
||
}
|
||
|
||
func imageTestDefaults(profile *ImageCapabilityConfig) (string, string) {
|
||
if profile == nil {
|
||
return "1024x1024", "auto"
|
||
}
|
||
size := ""
|
||
if profile.Size.Parameter != "none" {
|
||
size = strings.TrimSpace(profile.Size.Default)
|
||
}
|
||
quality := ""
|
||
if profile.Quality.Supported {
|
||
quality = strings.TrimSpace(profile.Quality.Default)
|
||
}
|
||
return size, quality
|
||
}
|
||
|
||
func normalizeChannelModelContract(channel *model.ModelChannel, req ChannelModelRequest) (string, string, string, model.ChannelInterfaceType, error) {
|
||
return normalizeChannelModelContractWithRegistry(protocol.Builtins(), channel, req)
|
||
}
|
||
|
||
func (s *Service) normalizeChannelModelContract(channel *model.ModelChannel, req ChannelModelRequest) (string, string, string, model.ChannelInterfaceType, error) {
|
||
return normalizeChannelModelContractWithRegistry(s.protocolRegistry(), channel, req)
|
||
}
|
||
|
||
func normalizeChannelModelContractWithRegistry(registry *protocol.Registry, channel *model.ModelChannel, req ChannelModelRequest) (string, string, string, model.ChannelInterfaceType, error) {
|
||
modelKey := strings.TrimPrefix(strings.TrimSpace(req.ModelKey), "models/")
|
||
if modelKey == "" {
|
||
return "", "", "", "", BadAuthRequest("请填写模型标识")
|
||
}
|
||
providerModelKey := strings.TrimPrefix(strings.TrimSpace(req.ProviderModelKey), "models/")
|
||
if providerModelKey == "" {
|
||
providerModelKey = modelKey
|
||
}
|
||
capability := normalizeCapability(req.Capability)
|
||
if capability == "" {
|
||
return "", "", "", "", BadAuthRequest("请选择模型能力")
|
||
}
|
||
adapter, ok := registry.Resolve(strings.TrimSpace(req.Protocol))
|
||
if !ok || !adapter.Metadata().Enabled || adapter.Metadata().UnavailableReason != "" {
|
||
return "", "", "", "", BadAuthRequest("请选择有效的模型请求协议")
|
||
}
|
||
protocol := model.ChannelInterfaceType(adapter.Metadata().ID)
|
||
if expected := protocolCapabilityFromMetadata(adapter.Metadata()); expected != "" && expected != capability {
|
||
return "", "", "", "", BadAuthRequest("模型能力与请求协议不匹配")
|
||
}
|
||
if (protocol == model.ChannelInterfaceVolcengineJiMengImage || protocol == model.ChannelInterfaceVolcengineJiMengVideo) && (strings.TrimSpace(channel.APIKey) == "" || strings.TrimSpace(channel.SecretKey) == "") {
|
||
return "", "", "", "", BadAuthRequest("即梦官方协议需要先在渠道中配置 Access Key 和 Secret Key")
|
||
}
|
||
return modelKey, providerModelKey, capability, protocol, nil
|
||
}
|
||
|
||
func (s *Service) DeleteAdminChannelModel(actor *model.User, channelID string, id string) error {
|
||
_, err := s.DeleteAdminChannelModels(actor, channelID, []string{id})
|
||
return err
|
||
}
|
||
|
||
// DeleteAdminChannelModels validates the complete selection before asking the
|
||
// repository to remove it atomically. This deliberately rejects partial success:
|
||
// administrators can safely correct an in-use model and retry the same selection.
|
||
func (s *Service) DeleteAdminChannelModels(actor *model.User, channelID string, ids []string) (int64, error) {
|
||
if err := s.RequireAdmin(actor); err != nil {
|
||
return 0, err
|
||
}
|
||
if _, err := s.repo.AdminSystemChannel(channelID); err != nil {
|
||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||
return 0, BadAuthRequest("系统渠道不存在或已删除")
|
||
}
|
||
return 0, err
|
||
}
|
||
modelIDs, err := normalizeAdminChannelModelDeleteIDs(ids)
|
||
if err != nil {
|
||
return 0, err
|
||
}
|
||
items, err := s.repo.ChannelModels(channelID, true)
|
||
if err != nil {
|
||
return 0, err
|
||
}
|
||
selected := make(map[string]bool, len(modelIDs))
|
||
for _, id := range modelIDs {
|
||
selected[id] = true
|
||
}
|
||
found := 0
|
||
for _, item := range items {
|
||
if selected[item.ID] {
|
||
found++
|
||
}
|
||
}
|
||
if found != len(modelIDs) {
|
||
return 0, BadAuthRequest("所选渠道模型中存在已删除或不属于当前渠道的记录,请刷新后重试")
|
||
}
|
||
// 删除模型与渠道的兼容模型清单必须同事务提交,避免接口报错但列表已部分变化。
|
||
deleted, err := s.repo.DeleteChannelModels(channelID, modelIDs, time.Now())
|
||
if errors.Is(err, repository.ErrChannelModelInUse) {
|
||
return 0, BadAuthRequest("所选渠道模型中有模型仍被前台模型供应线路或进行中任务使用,本次未删除任何模型")
|
||
}
|
||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||
return 0, BadAuthRequest("所选渠道模型中存在已删除或不属于当前渠道的记录,请刷新后重试")
|
||
}
|
||
if err == nil {
|
||
s.invalidateRouteCatalog()
|
||
}
|
||
return deleted, err
|
||
}
|
||
|
||
func normalizeAdminChannelModelDeleteIDs(values []string) ([]string, error) {
|
||
result := make([]string, 0, len(values))
|
||
seen := make(map[string]bool, len(values))
|
||
for _, value := range values {
|
||
id := strings.TrimSpace(value)
|
||
if id == "" || seen[id] {
|
||
continue
|
||
}
|
||
seen[id] = true
|
||
result = append(result, id)
|
||
}
|
||
if len(result) == 0 {
|
||
return nil, BadAuthRequest("请至少选择一个要删除的渠道模型")
|
||
}
|
||
if len(result) > maxAdminChannelModelBatchDeleteCount {
|
||
return nil, BadAuthRequest("单次最多删除 100 个渠道模型")
|
||
}
|
||
return result, nil
|
||
}
|
||
|
||
func (s *Service) syncInitialChannelModels(channel *model.ModelChannel, names []string) error {
|
||
existing, err := s.repo.ChannelModels(channel.ID, true)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
byKey := make(map[string]*model.ChannelModel, len(existing))
|
||
for index := range existing {
|
||
byKey[existing[index].ModelKey] = &existing[index]
|
||
}
|
||
desired := make(map[string]bool, len(names))
|
||
retired := retiredChannelModelKeys(channel.RetiredModelsJSON)
|
||
for _, name := range uniqueNonEmpty(names) {
|
||
name = strings.TrimPrefix(name, "models/")
|
||
if retired[channelModelCatalogKey(name)] {
|
||
continue
|
||
}
|
||
desired[name] = true
|
||
if item := byKey[name]; item != nil {
|
||
continue
|
||
}
|
||
modelID, idErr := s.repo.NextPrefixedID("MODEL")
|
||
if idErr != nil {
|
||
return idErr
|
||
}
|
||
item := model.ChannelModel{ID: modelID, ChannelID: channel.ID, ModelKey: name, DisplayName: name, BillingMode: "fixed_request", Enabled: false, PriceConfigured: false, UnitPriceMicrocredits: 0, PriceVersion: 1}
|
||
if err := s.repo.SaveChannelModel(&item); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
for index := range existing {
|
||
if !desired[existing[index].ModelKey] {
|
||
changed := existing[index].Enabled
|
||
existing[index].Enabled = false
|
||
if changed {
|
||
existing[index].PriceVersion++
|
||
if err := s.repo.SaveChannelModel(&existing[index]); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func retiredChannelModelKeys(raw string) map[string]bool {
|
||
var values []string
|
||
_ = json.Unmarshal([]byte(raw), &values)
|
||
result := make(map[string]bool, len(values))
|
||
for _, value := range values {
|
||
if key := channelModelCatalogKey(value); key != "" {
|
||
result[key] = true
|
||
}
|
||
}
|
||
return result
|
||
}
|
||
|
||
func channelModelCatalogKey(value string) string {
|
||
return strings.ToLower(strings.TrimPrefix(strings.TrimSpace(value), "models/"))
|
||
}
|
||
|
||
func (s *Service) ensureChannelModels(channelID string, includeDisabled bool) ([]model.ChannelModel, error) {
|
||
items, err := s.repo.ChannelModels(channelID, includeDisabled)
|
||
if err != nil || len(items) > 0 {
|
||
return items, err
|
||
}
|
||
channel, err := s.repo.AdminSystemChannel(channelID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if err := s.syncInitialChannelModels(channel, channelModelNames(*channel)); err != nil {
|
||
return nil, err
|
||
}
|
||
return s.repo.ChannelModels(channelID, includeDisabled)
|
||
}
|
||
|
||
func (s *Service) syncChannelModelNames(channel *model.ModelChannel) error {
|
||
return s.repo.SyncChannelModelNames(channel.ID, time.Now())
|
||
}
|
||
|
||
func (s *Service) capabilityForProtocol(protocol model.ChannelInterfaceType) string {
|
||
metadata, ok := s.channelProtocolMetadata(string(protocol))
|
||
if !ok {
|
||
return ""
|
||
}
|
||
return protocolCapabilityFromMetadata(metadata)
|
||
}
|
||
|
||
func protocolCapabilityFromMetadata(metadata protocol.Metadata) string {
|
||
if len(metadata.Categories) == 0 {
|
||
return ""
|
||
}
|
||
return string(metadata.Categories[0])
|
||
}
|