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

790 lines
33 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 (
"encoding/json"
"errors"
"fmt"
"regexp"
"strings"
"time"
"gorm.io/gorm"
"infinite-canvas/backend/internal/model"
"infinite-canvas/backend/internal/repository"
)
var logicalModelCodePattern = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]{1,79}$`)
type LogicalModelRequest struct {
Code string `json:"code"`
Name string `json:"name"`
Icon string `json:"icon"`
Description string `json:"description"`
Capability string `json:"capability"`
Enabled bool `json:"enabled"`
SortOrder int `json:"sortOrder"`
PricePolicy string `json:"pricePolicy"`
BillingMode string `json:"billingMode"`
UnitPriceMicrocredits int64 `json:"unitPriceMicrocredits"`
InputPriceMicrocredits int64 `json:"inputPriceMicrocredits"`
OutputPriceMicrocredits int64 `json:"outputPriceMicrocredits"`
CachedPriceMicrocredits int64 `json:"cachedPriceMicrocredits"`
// LegacyModelIDs 只用于将用户本地保存的旧目录选择迁移到当前模型家族,
// 不能用它重写任务、账单或路由尝试中的不可变快照。
LegacyModelIDs []string `json:"legacyModelIds"`
CapabilitySpec CapabilitySpec `json:"capabilitySpec"`
DefaultOptions map[string]any `json:"defaultOptions"`
Routes []LogicalRouteRequest `json:"routes"`
// SourceChannelModelID 仅供系统渠道同步流程使用,前台模型不再拥有独立的能力和价格真相。
SourceChannelModelID string `json:"-"`
}
type LogicalRouteRequest struct {
ChannelModelID string `json:"channelModelId"`
Enabled bool `json:"enabled"`
Priority int `json:"priority"`
Weight int `json:"weight"`
}
type PublicLogicalModel struct {
ID string `json:"id"`
Code string `json:"code"`
Name string `json:"name"`
Icon string `json:"icon"`
Description string `json:"description"`
Capability string `json:"capability"`
SortOrder int `json:"sortOrder"`
PricePolicy string `json:"pricePolicy"`
PricingMode string `json:"pricingMode"`
DisplayPrice *int64 `json:"displayPrice,omitempty"`
PriceLabel string `json:"priceLabel"`
BillingMode string `json:"billingMode"`
UnitPriceMicrocredits int64 `json:"unitPriceMicrocredits"`
InputPriceMicrocredits int64 `json:"inputPriceMicrocredits"`
OutputPriceMicrocredits int64 `json:"outputPriceMicrocredits"`
CachedPriceMicrocredits int64 `json:"cachedPriceMicrocredits"`
PriceTiers []PublicLogicalModelPriceTier `json:"priceTiers"`
LegacyModelIDs []string `json:"legacyModelIds"`
CapabilitySpec CapabilitySpec `json:"capabilitySpec"`
// CapabilityProfiles 是创作端可见的匿名能力组合,不暴露其背后的供应线路关系。
CapabilityProfiles []CapabilitySpec `json:"capabilityProfiles"`
DefaultOptions map[string]any `json:"defaultOptions"`
Available bool `json:"available"`
}
// PublicLogicalModelPriceTier 是创作端用于约束规格选择和展示当前报价的安全投影,
// 不暴露供应渠道、上游模型 ID 或内部路由信息。
type PublicLogicalModelPriceTier struct {
Selector map[string]string `json:"selector"`
Resolution string `json:"resolution"`
VideoSeconds int `json:"videoSeconds"`
BillingMode string `json:"billingMode"`
UnitPriceMicrocredits int64 `json:"unitPriceMicrocredits"`
InputTokenPriceMicrocredits int64 `json:"inputTokenPriceMicrocredits"`
OutputTokenPriceMicrocredits int64 `json:"outputTokenPriceMicrocredits"`
CachedTokenPriceMicrocredits int64 `json:"cachedTokenPriceMicrocredits"`
}
type AdminLogicalRoute struct {
ID string `json:"id"`
ChannelModelID string `json:"channelModelId"`
ChannelID string `json:"channelId"`
ChannelModelKey string `json:"channelModelKey"`
ChannelModelName string `json:"channelModelName"`
Enabled bool `json:"enabled"`
Priority int `json:"priority"`
Weight int `json:"weight"`
Available bool `json:"available"`
structurallyAvailable bool
CapabilitySpec CapabilitySpec `json:"capabilitySpec"`
}
type AdminLogicalModel struct {
PublicLogicalModel
Enabled bool `json:"enabled"`
ActiveRevisionID string `json:"activeRevisionId"`
RevisionVersion int `json:"revisionVersion"`
ConfigurationError string `json:"configurationError,omitempty"`
AvailabilityError string `json:"availabilityError,omitempty"`
Routes []AdminLogicalRoute `json:"routes"`
}
type RouteSimulationCandidate struct {
RouteID string `json:"routeId"`
ChannelModelID string `json:"channelModelId"`
ChannelModelKey string `json:"channelModelKey"`
ChannelModelName string `json:"channelModelName"`
Priority int `json:"priority"`
Weight int `json:"weight"`
Enabled bool `json:"enabled"`
Matched bool `json:"matched"`
Blocked bool `json:"blocked"`
InPool bool `json:"inPool"`
Reasons []string `json:"reasons,omitempty"`
}
type RouteSimulationResult struct {
ProductMatch CapabilityMatch `json:"productMatch"`
Candidates []RouteSimulationCandidate `json:"candidates"`
}
func (s *Service) AdminLogicalModels(actor *model.User) ([]AdminLogicalModel, error) {
if err := s.RequireAdmin(actor); err != nil {
return nil, err
}
items, err := s.repo.LogicalModels(true)
if err != nil {
return nil, err
}
graphs, err := s.repo.LogicalModelGraphs(items, true)
if err != nil {
return nil, err
}
systemChannelIDs := make([]string, 0)
for _, graph := range graphs {
if graph == nil {
continue
}
for _, channelModel := range graph.ChannelModels {
systemChannelIDs = append(systemChannelIDs, channelModel.ChannelID)
}
}
systemChannels, err := s.repo.SystemChannelsByIDs(systemChannelIDs, true)
if err != nil {
return nil, err
}
systemChannelByID := make(map[string]model.ModelChannel, len(systemChannels))
for _, channel := range systemChannels {
systemChannelByID[channel.ID] = channel
}
result := make([]AdminLogicalModel, 0, len(items))
for _, item := range items {
graph := graphs[item.ID]
if graph == nil || graph.Revision == nil {
continue
}
admin, buildErr := s.buildAdminLogicalModel(item, graph, systemChannelByID)
if buildErr != nil {
return nil, buildErr
}
result = append(result, *admin)
}
return result, nil
}
func (s *Service) buildAdminLogicalModel(item model.LogicalModel, graph *repository.LogicalModelGraph, systemChannelByID map[string]model.ModelChannel) (*AdminLogicalModel, error) {
productSpec, err := DecodeCapabilitySpec(graph.Revision.CapabilitySpecJSON)
if err != nil {
return nil, err
}
channelModelByID := make(map[string]model.ChannelModel, len(graph.ChannelModels))
for _, channelModel := range graph.ChannelModels {
channelModelByID[channelModel.ID] = channelModel
}
admin := AdminLogicalModel{PublicLogicalModel: publicLogicalModel(cachedLogicalModel{Model: item, ProductSpec: productSpec, Defaults: map[string]any{}}, false), Enabled: item.Enabled, ActiveRevisionID: graph.Revision.ID, RevisionVersion: graph.Revision.Version, Routes: []AdminLogicalRoute{}}
for _, route := range graph.Routes {
channelModel, channelOK := channelModelByID[route.ChannelModelID]
if !channelOK {
return nil, errors.New("供应线路引用的渠道模型不存在")
}
capabilitySpec, specErr := channelModelCapabilitySpec(channelModel)
if specErr != nil {
return nil, specErr
}
_, channelOK = systemChannelByID[channelModel.ChannelID]
structurallyAvailable := route.Enabled && route.Weight > 0 && channelModel.Enabled && channelOK
billingAvailable := item.PricePolicy != "unified" || item.BillingMode != "token" || supportsTokenBilling(item.Capability, channelModel.Protocol)
available := structurallyAvailable && billingAvailable && (item.PricePolicy != "channel" || channelModel.PriceConfigured)
admin.Routes = append(admin.Routes, AdminLogicalRoute{ID: route.ID, ChannelModelID: channelModel.ID, ChannelID: channelModel.ChannelID, ChannelModelKey: channelModel.ModelKey, ChannelModelName: channelModel.DisplayName, Enabled: route.Enabled, Priority: route.Priority, Weight: route.Weight, Available: available, structurallyAvailable: structurallyAvailable, CapabilitySpec: capabilitySpec})
}
routeSpecs := make([]CapabilitySpec, 0, len(admin.Routes))
for _, route := range admin.Routes {
routeSpecs = append(routeSpecs, route.CapabilitySpec)
}
productSpec = capabilitySpecWithRoutePresets(productSpec, routeSpecs)
defaults, err := decodeLogicalDefaults(graph.Revision.DefaultOptionsJSON, productSpec)
if err != nil {
return nil, err
}
admin.PublicLogicalModel = publicLogicalModel(cachedLogicalModel{Model: item, ProductSpec: productSpec, Defaults: defaults}, false)
// publicLogicalModel above has no route list; admin routes are already attached
// and the enriched product spec is the source used by the editor.
admin.CapabilitySpec = productSpec
admin.DefaultOptions = defaults
structuralRouteSpecs := structuralAdminRouteSpecs(admin.Routes)
settlementRouteSpecs := settlementReadyAdminRouteSpecs(admin.Routes)
admin.ConfigurationError = logicalModelConfigurationError(productSpec, structuralRouteSpecs)
admin.AvailabilityError = logicalModelAvailabilityError(item.PricePolicy, productSpec, structuralRouteSpecs, settlementRouteSpecs)
admin.Available = len(settlementRouteSpecs) > 0 && admin.ConfigurationError == "" && admin.AvailabilityError == ""
return &admin, nil
}
func countAvailableAdminRoutes(routes []AdminLogicalRoute) int {
count := 0
for _, route := range routes {
if route.Available {
count++
}
}
return count
}
func structuralAdminRouteSpecs(routes []AdminLogicalRoute) []CapabilitySpec {
result := make([]CapabilitySpec, 0, len(routes))
for _, route := range routes {
if route.structurallyAvailable {
result = append(result, route.CapabilitySpec)
}
}
return result
}
func settlementReadyAdminRouteSpecs(routes []AdminLogicalRoute) []CapabilitySpec {
result := make([]CapabilitySpec, 0, countAvailableAdminRoutes(routes))
for _, route := range routes {
if route.Available {
result = append(result, route.CapabilitySpec)
}
}
return result
}
func availableCachedRouteSpecs(routes []cachedLogicalRoute) []CapabilitySpec {
result := make([]CapabilitySpec, 0, len(routes))
for _, route := range routes {
if route.Route.Enabled && route.Route.Weight > 0 {
result = append(result, route.CapabilitySpec)
}
}
return result
}
func hasHealthyCachedRoute(s *Service, routes []cachedLogicalRoute) bool {
for _, route := range routes {
if route.Route.Enabled && route.Route.Weight > 0 && !s.logicalRouteBlocked(route) {
return true
}
}
return false
}
func logicalModelCapabilityCovered(product CapabilitySpec, routeSpecs []CapabilitySpec) bool {
return len(routeSpecs) > 0 && validateProductSpecWithinRoutes(product, routeSpecs) == nil
}
func logicalModelConfigurationError(product CapabilitySpec, routeSpecs []CapabilitySpec) string {
if len(routeSpecs) == 0 || logicalModelCapabilityCovered(product, routeSpecs) {
return ""
}
return "供应线路已无法完整覆盖创作端能力,请调整线路或能力范围"
}
func logicalModelAvailabilityError(pricePolicy string, product CapabilitySpec, structuralRouteSpecs, settlementRouteSpecs []CapabilitySpec) string {
if pricePolicy != "channel" || len(structuralRouteSpecs) == 0 {
return ""
}
if len(settlementRouteSpecs) == 0 {
return "供应线路尚未配置可结算价格,请先完善渠道模型价格"
}
if !logicalModelCapabilityCovered(product, settlementRouteSpecs) {
return "部分创作端能力只能由未配置价格的渠道模型承接,请完善对应价格"
}
return ""
}
func (s *Service) SaveAdminLogicalModel(actor *model.User, id string, req LogicalModelRequest) (*AdminLogicalModel, error) {
if err := s.RequireAdmin(actor); err != nil {
return nil, err
}
item, revision, routes, creating, err := s.logicalModelBundle(actor, id, req)
if err != nil {
return nil, err
}
if err := s.repo.SaveLogicalModelBundle(item, revision, routes, creating); err != nil {
return nil, err
}
s.invalidateRouteCatalog()
// 模型、版本和路由属于后台关键配置,保存成功却缺失审计记录不能静默返回成功。
if err := s.appendAdminAudit(actor, map[bool]string{true: "logical_model.create", false: "logical_model.update"}[creating], "logical_model", item.ID, "保存前台模型及供应线路", map[string]any{"revisionId": revision.ID, "routeCount": len(routes)}); err != nil {
return nil, err
}
graph, err := s.repo.LogicalModelGraph(item.ID, true)
if err != nil {
return nil, err
}
channelIDs := make([]string, 0, len(graph.ChannelModels))
for _, channelModel := range graph.ChannelModels {
channelIDs = append(channelIDs, channelModel.ChannelID)
}
systemChannels, err := s.repo.SystemChannelsByIDs(channelIDs, true)
if err != nil {
return nil, err
}
systemChannelByID := make(map[string]model.ModelChannel, len(systemChannels))
for _, channel := range systemChannels {
systemChannelByID[channel.ID] = channel
}
return s.buildAdminLogicalModel(*item, graph, systemChannelByID)
}
func (s *Service) DeleteAdminLogicalModel(actor *model.User, id string) error {
if err := s.RequireAdmin(actor); err != nil {
return err
}
item, err := s.repo.LogicalModel(strings.TrimSpace(id))
if logicalModelNotFound(err) {
return BadAuthRequest("前台模型不存在或已删除")
}
if err != nil {
return err
}
if item.ArchivedAt != nil {
return BadAuthRequest("前台模型不存在或已删除")
}
if item.SourceChannelModelID != "" {
return BadAuthRequest("该前台模型由系统渠道自动同步,请在系统渠道模型中停用")
}
audit, err := newAdminAuditEvent(actor, "logical_model.archive", "logical_model", item.ID, "归档前台模型", map[string]any{"code": item.Code, "name": item.Name})
if err != nil {
return err
}
if err := s.repo.ArchiveLogicalModel(item.ID, audit, time.Now()); err != nil {
if errors.Is(err, repository.ErrLogicalModelInUse) {
return BadAuthRequest("前台模型仍被排队中或进行中任务使用,请等待任务结束后再归档")
}
if logicalModelNotFound(err) {
return BadAuthRequest("前台模型不存在或已删除")
}
return err
}
s.invalidateRouteCatalog()
return nil
}
func (s *Service) logicalModelBundle(actor *model.User, id string, req LogicalModelRequest) (*model.LogicalModel, *model.LogicalModelRevision, []model.LogicalModelRoute, bool, error) {
code := strings.ToLower(strings.TrimSpace(req.Code))
name := strings.TrimSpace(req.Name)
capability := normalizeCapability(req.Capability)
if !logicalModelCodePattern.MatchString(code) {
return nil, nil, nil, false, BadAuthRequest("模型 code 需为 2-80 位小写字母、数字、点、下划线或连字符")
}
if name == "" || len([]rune(name)) > 120 {
return nil, nil, nil, false, BadAuthRequest("请填写 1-120 个字符的模型名称")
}
sourceChannelModelID := strings.TrimSpace(req.SourceChannelModelID)
if sourceChannelModelID != "" {
source, sourceErr := s.repo.ChannelModel(sourceChannelModelID)
if sourceErr != nil {
return nil, nil, nil, false, BadAuthRequest("系统渠道模型不存在")
}
if _, channelErr := s.repo.AdminSystemChannel(source.ChannelID); channelErr != nil {
return nil, nil, nil, false, BadAuthRequest("前台模型只能同步系统渠道模型")
}
capability = normalizeCapability(source.Capability)
derivedSpec, specErr := channelModelCapabilitySpec(*source)
if specErr != nil {
return nil, nil, nil, false, specErr
}
derivedDefaults, defaultsErr := channelModelDefaultOptions(*source, derivedSpec)
if defaultsErr != nil {
return nil, nil, nil, false, defaultsErr
}
if len(req.Routes) == 0 {
req.CapabilitySpec = derivedSpec
req.DefaultOptions = derivedDefaults
req.Routes = []LogicalRouteRequest{{ChannelModelID: source.ID, Enabled: true, Priority: 100, Weight: 100}}
}
req.PricePolicy = "channel"
req.BillingMode = "fixed_request"
req.UnitPriceMicrocredits, req.InputPriceMicrocredits, req.OutputPriceMicrocredits, req.CachedPriceMicrocredits = 0, 0, 0, 0
// 未完成定价的系统模型仅在后台目录保留同步记录,不能暴露到创作端。
if strings.TrimSpace(id) == "" {
req.Enabled = source.Enabled && channelModelHasActivePriceTier(*source)
}
}
normalizedSpec, err := NormalizeCapabilitySpec(req.CapabilitySpec)
if err != nil {
return nil, nil, nil, false, err
}
req.CapabilitySpec = normalizedSpec
if normalizeCapability(req.CapabilitySpec.Capability) != capability {
return nil, nil, nil, false, BadAuthRequest("前台模型类型与能力配置不一致")
}
normalizedDefaults, err := normalizeLogicalDefaults(req.CapabilitySpec, req.DefaultOptions)
if err != nil {
return nil, nil, nil, false, err
}
req.DefaultOptions = normalizedDefaults
if req.UnitPriceMicrocredits < 0 || req.InputPriceMicrocredits < 0 || req.OutputPriceMicrocredits < 0 || req.CachedPriceMicrocredits < 0 {
return nil, nil, nil, false, BadAuthRequest("用户价格不能为负数")
}
pricePolicy := strings.TrimSpace(req.PricePolicy)
if pricePolicy != "channel" && pricePolicy != "unified" {
return nil, nil, nil, false, BadAuthRequest("请选择跟随供应价格或统一定价")
}
billingMode := strings.TrimSpace(req.BillingMode)
if pricePolicy == "channel" {
billingMode = "fixed_request"
req.UnitPriceMicrocredits, req.InputPriceMicrocredits, req.OutputPriceMicrocredits, req.CachedPriceMicrocredits = 0, 0, 0, 0
} else if billingMode != "fixed_request" && billingMode != "per_second" && billingMode != "token" {
return nil, nil, nil, false, BadAuthRequest("前台模型计费方式仅支持按次、按秒或 Token")
}
if pricePolicy == "unified" && billingMode == "per_second" && capability != "video" {
return nil, nil, nil, false, BadAuthRequest("只有视频前台模型可以按秒计费")
}
if pricePolicy == "unified" && billingMode == "token" {
if err := validateTokenPrices(capability, req.InputPriceMicrocredits, req.OutputPriceMicrocredits, req.CachedPriceMicrocredits); err != nil {
return nil, nil, nil, false, err
}
}
creating := strings.TrimSpace(id) == ""
var item *model.LogicalModel
if creating {
id, err = s.repo.NextPrefixedID("LMODEL")
if err != nil {
return nil, nil, nil, false, err
}
item = &model.LogicalModel{ID: id, CreatedAt: time.Now()}
} else {
item, err = s.repo.LogicalModel(id)
if err != nil {
return nil, nil, nil, false, err
}
if item.ArchivedAt != nil {
return nil, nil, nil, false, BadAuthRequest("前台模型不存在或已删除")
}
}
item.Code, item.Name, item.Icon, item.Description, item.Capability = code, name, strings.TrimSpace(req.Icon), strings.TrimSpace(req.Description), capability
if sourceChannelModelID != "" {
item.SourceChannelModelID = sourceChannelModelID
}
item.Enabled, item.SortOrder, item.PricePolicy, item.BillingMode = req.Enabled, req.SortOrder, pricePolicy, billingMode
item.UnitPriceMicrocredits, item.InputPriceMicrocredits, item.OutputPriceMicrocredits, item.CachedPriceMicrocredits = req.UnitPriceMicrocredits, req.InputPriceMicrocredits, req.OutputPriceMicrocredits, req.CachedPriceMicrocredits
if req.LegacyModelIDs != nil {
legacyJSON, marshalErr := json.Marshal(normalizeLegacyModelIDs(req.LegacyModelIDs))
if marshalErr != nil {
return nil, nil, nil, false, marshalErr
}
item.LegacyModelIDsJSON = string(legacyJSON)
}
item.UpdatedAt = time.Now()
revisionID, err := s.repo.NextPrefixedID("REVISION")
if err != nil {
return nil, nil, nil, false, err
}
specJSON, err := json.Marshal(req.CapabilitySpec)
if err != nil {
return nil, nil, nil, false, fmt.Errorf("序列化前台模型能力合同失败:%w", err)
}
defaultsJSON, err := json.Marshal(defaultMap(req.DefaultOptions))
if err != nil {
return nil, nil, nil, false, fmt.Errorf("序列化前台模型默认参数失败:%w", err)
}
revision := &model.LogicalModelRevision{ID: revisionID, LogicalModelID: item.ID, CapabilitySpecJSON: string(specJSON), DefaultOptionsJSON: string(defaultsJSON), CreatedBy: actor.ID, CreatedAt: time.Now()}
routes := make([]model.LogicalModelRoute, 0, len(req.Routes))
seenChannelModels := make(map[string]bool, len(req.Routes))
structuralRouteSpecs := make([]CapabilitySpec, 0, len(req.Routes))
settlementRouteSpecs := make([]CapabilitySpec, 0, len(req.Routes))
enabledRouteProtocols := make([]model.ChannelInterfaceType, 0, len(req.Routes))
for _, input := range req.Routes {
channelModelID := strings.TrimSpace(input.ChannelModelID)
if channelModelID == "" || seenChannelModels[channelModelID] {
return nil, nil, nil, false, BadAuthRequest("供应线路必须选择不重复的渠道模型")
}
seenChannelModels[channelModelID] = true
channelModel, modelErr := s.repo.ChannelModel(channelModelID)
if modelErr != nil {
return nil, nil, nil, false, BadAuthRequest("供应线路引用的渠道模型不存在")
}
capabilitySpec, specErr := channelModelCapabilitySpec(*channelModel)
if specErr != nil {
return nil, nil, nil, false, specErr
}
if normalizeCapability(capabilitySpec.Capability) != capability {
return nil, nil, nil, false, BadAuthRequest("供应线路能力类型与前台模型不一致")
}
if input.Enabled {
enabledRouteProtocols = append(enabledRouteProtocols, channelModel.Protocol)
}
if req.Enabled && input.Enabled && input.Weight <= 0 {
return nil, nil, nil, false, BadAuthRequest("启用供应线路的同级权重必须大于 0")
}
if input.Weight < 0 {
return nil, nil, nil, false, BadAuthRequest("供应线路的同级权重不能为负数")
}
if _, channelErr := s.repo.SystemChannel(channelModel.ChannelID); channelErr != nil {
return nil, nil, nil, false, BadAuthRequest("供应线路只能选择系统渠道模型")
}
if req.Enabled && input.Enabled && channelModel.Enabled {
structuralRouteSpecs = append(structuralRouteSpecs, capabilitySpec)
if pricePolicy != "channel" || channelModel.PriceConfigured {
settlementRouteSpecs = append(settlementRouteSpecs, capabilitySpec)
}
}
routeID, idErr := s.repo.NextPrefixedID("ROUTE")
if idErr != nil {
return nil, nil, nil, false, idErr
}
routes = append(routes, model.LogicalModelRoute{ID: routeID, ChannelModelID: channelModel.ID, Enabled: input.Enabled, Priority: input.Priority, Weight: input.Weight, CreatedAt: time.Now(), UpdatedAt: time.Now()})
}
if pricePolicy == "unified" && billingMode == "token" {
if !supportsLogicalModelTokenBilling(capability, enabledRouteProtocols) {
return nil, nil, nil, false, BadAuthRequest("Token 计费仅支持文本和视频前台模型")
}
}
// 停用必须始终可执行,便于管理员立即阻止失效线路继续对外服务;重新启用时再强校验结构能力和计费可用性。
if req.Enabled {
if len(structuralRouteSpecs) == 0 {
return nil, nil, nil, false, BadAuthRequest("启用前台模型前至少需要一条已启用的供应线路")
}
if err := validateProductSpecWithinRoutes(req.CapabilitySpec, structuralRouteSpecs); err != nil {
return nil, nil, nil, false, err
}
if availabilityError := logicalModelAvailabilityError(pricePolicy, req.CapabilitySpec, structuralRouteSpecs, settlementRouteSpecs); availabilityError != "" {
return nil, nil, nil, false, BadAuthRequest(availabilityError)
}
}
return item, revision, routes, creating, nil
}
func supportsLogicalModelTokenBilling(capability string, enabledRouteProtocols []model.ChannelInterfaceType) bool {
if capability == "text" {
return true
}
if capability != "video" || len(enabledRouteProtocols) == 0 {
return false
}
for _, protocol := range enabledRouteProtocols {
if !supportsTokenBilling(capability, protocol) {
return false
}
}
return true
}
func normalizeLogicalDefaults(spec CapabilitySpec, defaults map[string]any) (map[string]any, error) {
result := make(map[string]any, len(defaults))
for rawName, value := range defaults {
name := canonicalCapabilityOptionName(rawName)
if _, exists := result[name]; exists {
return nil, BadAuthRequest("默认参数存在重复别名:" + name)
}
constraint, ok := spec.Options[name]
if !ok || !matchOptionConstraint(name, constraint, value) {
return nil, BadAuthRequest("默认参数 " + name + " 不在前台模型能力范围内")
}
// `*` 只表示允许任意自定义值,不能作为创作端默认参数发送。
if normalizedScalar(value) == "*" {
for _, candidate := range constraint.Values {
if normalizedScalar(candidate) != "*" {
value = candidate
break
}
}
}
result[name] = value
}
return result, nil
}
func defaultMap(value map[string]any) map[string]any {
if value == nil {
return map[string]any{}
}
return value
}
func channelModelCapabilitySpec(channelModel model.ChannelModel) (CapabilitySpec, error) {
config, err := DecodeModelCapabilityConfig(channelModel.CapabilityConfigJSON)
if err != nil {
return CapabilitySpec{}, BadAuthRequest("渠道模型能力配置无效,请先修复渠道模型")
}
if config != nil {
config, err = NormalizeModelCapabilityConfigForModel(normalizeCapability(channelModel.Capability), string(channelModel.Protocol), firstNonEmpty(channelModel.ProviderModelKey, channelModel.ModelKey), config)
if err != nil {
return CapabilitySpec{}, err
}
}
spec, err := CapabilitySpecFromModelCapabilityConfig(config, normalizeCapability(channelModel.Capability))
if err != nil {
return CapabilitySpec{}, err
}
return capabilitySpecWithPriceTiers(spec, channelModel), nil
}
// capabilitySpecWithPriceTiers 只让创作端选择已经启用且可结算的规格。规格组合最终仍由
// 路由时的精确价格档匹配保证,避免独立枚举无法表达“分辨率 × 时长”非笛卡尔组合的问题。
func capabilitySpecWithPriceTiers(spec CapabilitySpec, channelModel model.ChannelModel) CapabilitySpec {
if normalizeCapability(spec.Capability) != "video" || len(channelModel.PriceTiers) == 0 {
return spec
}
tiers := make([]model.ChannelModelPriceTier, 0, len(channelModel.PriceTiers))
for _, tier := range channelModel.PriceTiers {
if tier.Enabled && tier.PriceConfigured {
tiers = append(tiers, tier)
}
}
if len(tiers) == 0 {
return spec
}
result := spec
result.Options = make(map[string]OptionConstraint, len(spec.Options))
for name, option := range spec.Options {
result.Options[name] = option
}
hasResolutionWildcard, hasDurationWildcard := false, false
resolutions := make([]any, 0, len(tiers))
durations := make([]any, 0, len(tiers))
seenResolutions := make(map[string]bool, len(tiers))
seenDurations := make(map[int]bool, len(tiers))
for _, tier := range tiers {
if normalizeChannelModelTierResolution(tier.Resolution) == "*" {
hasResolutionWildcard = true
} else if value := normalizeChannelModelTierResolution(tier.Resolution); !seenResolutions[value] {
seenResolutions[value] = true
resolutions = append(resolutions, value)
}
if tier.VideoSeconds == 0 {
hasDurationWildcard = true
} else if !seenDurations[tier.VideoSeconds] {
seenDurations[tier.VideoSeconds] = true
durations = append(durations, tier.VideoSeconds)
}
}
if !hasResolutionWildcard && len(resolutions) > 0 {
result.Options["vquality"] = OptionConstraint{Values: resolutions}
}
if !hasDurationWildcard && len(durations) > 0 {
result.Options["videoSeconds"] = OptionConstraint{Values: durations}
}
return result
}
func channelModelDefaultOptions(channelModel model.ChannelModel, spec CapabilitySpec) (map[string]any, error) {
config, err := DecodeModelCapabilityConfig(channelModel.CapabilityConfigJSON)
if err != nil {
return nil, BadAuthRequest("渠道模型能力配置无效,请先修复渠道模型")
}
if config != nil {
config, err = NormalizeModelCapabilityConfigForModel(normalizeCapability(channelModel.Capability), string(channelModel.Protocol), firstNonEmpty(channelModel.ProviderModelKey, channelModel.ModelKey), config)
if err != nil {
return nil, err
}
}
defaults := make(map[string]any)
if config != nil {
switch normalizeCapability(channelModel.Capability) {
case "image":
if config.Image != nil {
defaults["size"] = config.Image.Size.Default
if config.Image.Quality.Supported {
defaults["quality"] = config.Image.Quality.Default
}
if config.Image.TransparentBackground.Supported {
defaults["transparentBackground"] = config.Image.TransparentBackground.Default
}
}
case "video":
if config.Video != nil {
defaults["videoSeconds"] = config.Video.Duration.Default
defaults["vquality"] = normalizeChannelModelTierResolution(config.Video.DefaultResolution)
defaults["size"] = config.Video.DefaultRatio
if config.Video.GenerateAudio.Supported {
defaults["videoGenerateAudio"] = config.Video.GenerateAudio.Default
}
if config.Video.Watermark.Supported {
defaults["videoWatermark"] = config.Video.Watermark.Default
}
}
}
}
// 当渠道默认规格没有价格档时,优先选择第一个可结算档,确保初始选择可直接生成。
if normalizeCapability(channelModel.Capability) == "video" && channelModelPriceTierForIntent(channelModel, ModelRequestIntent{Capability: "video", Options: defaults}) == nil {
for _, tier := range channelModel.PriceTiers {
if !tier.Enabled || !tier.PriceConfigured {
continue
}
if tier.Resolution != "*" {
defaults["vquality"] = normalizeChannelModelTierResolution(tier.Resolution)
}
if tier.VideoSeconds > 0 {
defaults["videoSeconds"] = tier.VideoSeconds
}
break
}
}
return normalizeLogicalDefaults(spec, defaults)
}
func (s *Service) SimulateLogicalModelRoute(actor *model.User, id string, intent ModelRequestIntent) (*RouteSimulationResult, error) {
if err := s.RequireAdmin(actor); err != nil {
return nil, err
}
snapshot, err := s.routeCatalogSnapshot()
if err != nil {
return nil, err
}
cached, ok := snapshot.Models[id]
if !ok {
return nil, BadAuthRequest("前台模型未启用或尚未发布")
}
intent.Options = mergeIntentDefaults(intent.Options, cached.Defaults)
return &RouteSimulationResult{ProductMatch: MatchCapability(cached.ProductSpec, intent), Candidates: s.sortedRouteDiagnostics(cached.Routes, intent)}, nil
}
func logicalModelNotFound(err error) bool { return errors.Is(err, gorm.ErrRecordNotFound) }
// computeModelPriceDisplay 计算模型的价格展示信息
// 返回:pricingMode, displayPrice, priceLabel
func computeModelPriceDisplay(model model.LogicalModel, priceTiers []PublicLogicalModelPriceTier) (string, *int64, string) {
if model.PricePolicy == "channel" {
// 跟随渠道价格
if len(priceTiers) == 0 {
return "provider", nil, "未配置"
}
// 检查是否所有价格档都相同
if len(priceTiers) == 1 {
tier := priceTiers[0]
price := getTierDisplayPrice(tier)
if price > 0 {
return "provider", &price, ""
}
}
// 多个价格档或价格为0,显示"按渠道规格计费"
return "provider", nil, "按渠道规格计费"
}
// 统一定价模式
if model.BillingMode == "fixed_request" && model.UnitPriceMicrocredits > 0 {
price := model.UnitPriceMicrocredits
return "unified", &price, ""
}
if model.BillingMode == "per_second" && model.UnitPriceMicrocredits > 0 {
price := model.UnitPriceMicrocredits
return "unified", &price, "按秒"
}
if model.BillingMode == "token" {
// Token 计费显示输入/输出价格
if model.InputPriceMicrocredits > 0 || model.OutputPriceMicrocredits > 0 {
return "unified", nil, "按 Token"
}
}
return "unified", nil, "未配置"
}
// getTierDisplayPrice 获取价格档的展示价格
func getTierDisplayPrice(tier PublicLogicalModelPriceTier) int64 {
if tier.BillingMode == "fixed_request" || tier.BillingMode == "per_second" {
return tier.UnitPriceMicrocredits
}
// Token 计费返回输出价格(如果有)
if tier.OutputTokenPriceMicrocredits > 0 {
return tier.OutputTokenPriceMicrocredits
}
if tier.InputTokenPriceMicrocredits > 0 {
return tier.InputTokenPriceMicrocredits
}
return 0
}