Files
ttstd-infinite-canvas/backend/internal/app/channel_models.go

1036 lines
39 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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])
}