Files
ttstd-infinite-canvas/backend/internal/app/model_router.go
2026-10-13 20:54:44 +08:00

586 lines
20 KiB
Go

package app
import (
"context"
cryptorand "crypto/rand"
"encoding/binary"
"fmt"
"log"
"math"
"sort"
"strconv"
"strings"
"time"
"yingce/backend/internal/model"
)
func (s *Service) invalidateRouteCatalog() {
s.routeCatalogRefreshMu.Lock()
s.routeCatalogMu.Lock()
s.routeCatalog = nil
s.routeCatalogVersion++
s.routeCatalogRetryAt = time.Time{}
s.routeCatalogRefreshError = nil
s.routeCatalogMu.Unlock()
s.routeCatalogRefreshMu.Unlock()
if s.coordinator != nil {
ctx, cancel := context.WithTimeout(context.Background(), runtimeCoordinationTimeout)
defer cancel()
if err := s.coordinator.BumpRouteCatalogVersion(ctx); err != nil {
log.Printf("logical model route catalog distributed invalidation failed: %v", err)
}
}
s.initReadCaches()
s.routeVersionReadCache.Clear()
}
func (s *Service) routeCatalogSnapshot() (*routeCatalogSnapshot, error) {
now := time.Now()
version := s.currentRouteCatalogVersion()
s.routeCatalogMu.RLock()
snapshot := s.routeCatalog
if snapshot != nil && now.Sub(snapshot.LoadedAt) < s.routeCatalogTTL && snapshot.CatalogVersion == version {
s.routeCatalogMu.RUnlock()
return snapshot, nil
}
s.routeCatalogMu.RUnlock()
s.routeCatalogRefreshMu.Lock()
defer s.routeCatalogRefreshMu.Unlock()
now = time.Now()
version = s.currentRouteCatalogVersion()
s.routeCatalogMu.RLock()
snapshot = s.routeCatalog
if snapshot != nil && now.Sub(snapshot.LoadedAt) < s.routeCatalogTTL && snapshot.CatalogVersion == version {
s.routeCatalogMu.RUnlock()
return snapshot, nil
}
s.routeCatalogMu.RUnlock()
// 刷新锁只能防止并行回源,失败后还需要冷却,否则等待者会逐个重打数据库。
if s.routeCatalogRefreshError != nil && now.Before(s.routeCatalogRetryAt) {
if snapshot != nil && snapshot.CatalogVersion == version && now.Sub(snapshot.LoadedAt) <= s.routeCatalogMaxStale {
return snapshot, nil
}
return nil, s.routeCatalogRefreshError
}
loaded, err := s.loadRouteCatalog()
if err != nil {
s.routeCatalogRefreshError = err
s.routeCatalogRetryAt = time.Now().Add(2 * time.Second)
log.Printf("logical model route catalog refresh failed; retry cooled down: %v", err)
// 已有快照过期时允许短暂继续服务,数据库首次加载失败则明确失败。
if snapshot != nil && snapshot.CatalogVersion == version && now.Sub(snapshot.LoadedAt) <= s.routeCatalogMaxStale {
return snapshot, nil
}
return nil, err
}
s.routeCatalogRefreshError = nil
s.routeCatalogRetryAt = time.Time{}
s.routeCatalogMu.Lock()
s.routeCatalog = loaded
s.routeCatalogMu.Unlock()
return loaded, nil
}
func (s *Service) currentRouteCatalogVersion() int64 {
s.routeCatalogMu.RLock()
localVersion := s.routeCatalogVersion
s.routeCatalogMu.RUnlock()
if s.coordinator == nil || !s.coordinator.HasRedis() {
return localVersion
}
s.initReadCaches()
ctx, cancel := context.WithTimeout(context.Background(), runtimeCoordinationTimeout)
defer cancel()
version, err := s.routeVersionReadCache.Get(ctx, routeCatalogVersionKey, func(ctx context.Context) (int64, int, error) {
value, err := s.coordinator.RouteCatalogVersion(ctx)
if err != nil {
log.Printf("logical model route catalog distributed version check failed: %v", err)
}
return value, 256, err
})
if err != nil {
return localVersion
}
if version > localVersion {
return version
}
return localVersion
}
func (s *Service) loadRouteCatalog() (*routeCatalogSnapshot, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
repo := s.repo.WithContext(ctx)
items, err := repo.LogicalModels(false)
if err != nil {
return nil, err
}
snapshot := &routeCatalogSnapshot{LoadedAt: time.Now(), CatalogVersion: s.currentRouteCatalogVersion(), Models: make(map[string]cachedLogicalModel), Ordered: make([]string, 0, len(items))}
graphs, err := repo.LogicalModelGraphs(items, false)
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 := repo.SystemChannelsByIDs(systemChannelIDs, false)
if err != nil {
return nil, err
}
enabledSystemChannels := make(map[string]bool, len(systemChannels))
for _, channel := range systemChannels {
enabledSystemChannels[channel.ID] = true
}
for _, item := range items {
graph := graphs[item.ID]
if graph == nil || graph.Revision == nil {
log.Printf("logical model omitted from route catalog id=%s: graph unavailable", item.ID)
continue
}
productSpec, decodeErr := DecodeCapabilitySpec(graph.Revision.CapabilitySpecJSON)
if decodeErr != nil {
log.Printf("logical model omitted from route catalog id=%s: invalid product capability: %v", item.ID, decodeErr)
continue
}
channelModelByID := make(map[string]model.ChannelModel, len(graph.ChannelModels))
for _, channelModel := range graph.ChannelModels {
channelModelByID[channelModel.ID] = channelModel
}
cached := cachedLogicalModel{Model: item, Revision: *graph.Revision, ProductSpec: productSpec, Defaults: map[string]any{}}
for _, route := range graph.Routes {
channelModel, ok := channelModelByID[route.ChannelModelID]
if !ok || !channelModel.Enabled || !enabledSystemChannels[channelModel.ChannelID] {
continue
}
if item.PricePolicy == "unified" && item.BillingMode == "token" && !supportsTokenBilling(item.Capability, channelModel.Protocol) {
continue
}
if item.PricePolicy == "channel" && !channelModelHasActivePriceTier(channelModel) {
continue
}
capabilitySpec, specErr := channelModelCapabilitySpec(channelModel, s.protocolRegistry())
if specErr != nil {
log.Printf("logical route omitted from catalog route_id=%s channel_model_id=%s: invalid capability: %v", route.ID, channelModel.ID, specErr)
continue
}
cached.Routes = append(cached.Routes, cachedLogicalRoute{Route: route, CapabilitySpec: capabilitySpec, ChannelModel: channelModel})
}
productSpec = capabilitySpecWithRoutePresets(productSpec, enabledLogicalRouteSpecs(cached.Routes))
defaults, defaultsErr := decodeLogicalDefaults(graph.Revision.DefaultOptionsJSON, productSpec)
if defaultsErr != nil {
log.Printf("logical model omitted from route catalog id=%s: invalid defaults: %v", item.ID, defaultsErr)
continue
}
cached.ProductSpec = productSpec
cached.Defaults = defaults
snapshot.Models[item.ID] = cached
snapshot.Ordered = append(snapshot.Ordered, item.ID)
}
return snapshot, nil
}
// ResolveLogicalModel 将创作意图解析为一次可执行的路由快照。
// 解析同时约束能力合同、启用状态、价格档和渠道协议;调用方不得在解析完成后自行替换其中任一供应链字段,
// 否则会出现“目录显示可用、任务实际走另一条线路”的配置漂移。
func (s *Service) ResolveLogicalModel(logicalModelID string, intent ModelRequestIntent) (*RoutedModel, error) {
snapshot, err := s.routeCatalogSnapshot()
if err != nil {
return nil, err
}
cached, ok := snapshot.Models[strings.TrimSpace(logicalModelID)]
if !ok {
return nil, BadAuthRequest("所选模型不可用")
}
intent = resolveModelRequestIntent(cached.ProductSpec, intent, cached.Defaults)
if parameters := cached.ProductSpec.ImageParameters; parameters != nil {
if _, err := resolveImageParameterValues(parameters, intent.Options); err != nil {
return nil, err
}
}
if match := MatchCapability(cached.ProductSpec, intent); !match.Matched {
return nil, modelCapabilityMismatchError(match)
}
eligible := s.eligibleLogicalRoutes(cached.Routes, intent, nil, cached.Model.PricePolicy == "channel")
if len(eligible) == 0 {
return nil, BadAuthRequest("当前模型暂时无法满足这组输入和参数")
}
selected := weightedRoute(eligible)
var priceTier *model.ChannelModelPriceTier
if cached.Model.PricePolicy == "channel" {
priceTier = channelModelPriceTierForIntent(selected.ChannelModel, intent, selected.CapabilitySpec)
if priceTier == nil {
return nil, BadAuthRequest("当前模型尚未配置所选规格的价格")
}
}
return &RoutedModel{LogicalModel: cached.Model, Revision: cached.Revision, Route: selected.Route, ChannelModel: selected.ChannelModel, CapabilitySpec: selected.CapabilitySpec, PriceTier: priceTier, Defaults: cached.Defaults, Intent: intent}, nil
}
func (s *Service) eligibleLogicalRoutes(routes []cachedLogicalRoute, intent ModelRequestIntent, tried map[string]bool, requirePriceTier bool) []cachedLogicalRoute {
eligible := make([]cachedLogicalRoute, 0, len(routes))
maxPriority := math.MinInt
for _, route := range routes {
if !route.Route.Enabled || route.Route.Weight <= 0 || tried[route.Route.ID] || s.logicalRouteBlocked(route) {
continue
}
if match := MatchCapability(route.CapabilitySpec, intent); !match.Matched {
continue
}
if requirePriceTier && channelModelPriceTierForIntent(route.ChannelModel, intent, route.CapabilitySpec) == nil {
continue
}
if route.Route.Priority > maxPriority {
eligible = eligible[:0]
maxPriority = route.Route.Priority
}
if route.Route.Priority == maxPriority {
eligible = append(eligible, route)
}
}
return eligible
}
func channelModelHasActivePriceTier(channelModel model.ChannelModel) bool {
for _, tier := range channelModel.PriceTiers {
if tier.Enabled && tier.PriceConfigured {
return true
}
}
return false
}
// channelModelPriceTierForIntent 使用“精确规格优先、通配规格兜底”的规则。SKU 选择器与
// 运行意图使用同一组规范键,因而图片质量/画幅、视频分辨率/时长和生成操作都能独立定价。
func channelModelPriceTierForIntent(channelModel model.ChannelModel, intent ModelRequestIntent, specs ...CapabilitySpec) *model.ChannelModelPriceTier {
selector := skuSelectorForIntent(intent)
parameters := imagePriceParametersForChannel(channelModel, specs...)
if parameters != nil {
selector = imagePriceSelectorForParameters(parameters, intent)
}
bestScore := -1
var best *model.ChannelModelPriceTier
for index := range channelModel.PriceTiers {
tier := &channelModel.PriceTiers[index]
if !tier.Enabled || !tier.PriceConfigured {
continue
}
tierSelector := skuSelectorForTier(*tier)
if parameters != nil {
tierSelector = model.DecodeSKUSelector(tier.SelectorJSON)
if validateImageParameterPriceSelector(parameters, tierSelector) != nil {
continue
}
}
matched, score := matchSKUSelector(tierSelector, selector)
if !matched {
continue
}
if score > bestScore {
best, bestScore = tier, score
}
}
return best
}
// channelModelHasCompatiblePriceTier is only for catalog discovery: an option
// omitted from the intent is still a choice, while every supplied selector
// value must be compatible with at least one active, configured tier.
func channelModelHasCompatiblePriceTier(channelModel model.ChannelModel, intent ModelRequestIntent, specs ...CapabilitySpec) bool {
requested := skuSelectorForIntent(intent)
parameters := imagePriceParametersForChannel(channelModel, specs...)
if parameters != nil {
requested = imagePriceSelectorForParameters(parameters, intent)
}
for _, tier := range channelModel.PriceTiers {
if !tier.Enabled || !tier.PriceConfigured {
continue
}
compatible := true
tierSelector := skuSelectorForTier(tier)
if parameters != nil {
tierSelector = model.DecodeSKUSelector(tier.SelectorJSON)
if validateImageParameterPriceSelector(parameters, tierSelector) != nil {
continue
}
}
for key, expected := range tierSelector {
expected = strings.TrimSpace(expected)
if expected == "" || expected == "*" {
continue
}
if actual, provided := requested[key]; provided && actual != expected {
compatible = false
break
}
}
if compatible {
return true
}
}
return false
}
func skuSelectorForIntent(intent ModelRequestIntent) map[string]string {
selector := map[string]string{}
if operation := strings.ToLower(strings.TrimSpace(intent.Operation)); operation != "" {
selector["operation"] = operation
}
switch normalizeCapability(intent.Capability) {
case "video":
// 价格档按实际参考素材归类。供应商执行仍可使用 reference_to_video、extend
// 等细分操作;计价时视频参考优先归为视频生视频,其余图片参考无论数量
// 都归为图生视频。
if intent.Inputs["video"] > 0 {
selector["operation"] = "video_to_video"
} else if intent.Inputs["image"] > 0 {
selector["operation"] = "image_to_video"
}
if count := intent.Inputs["image"]; count > 0 {
selector["imageCount"] = strconv.Itoa(count)
}
if value := normalizeChannelModelTierResolution(fmt.Sprint(intent.Options["vquality"])); value != "*" {
selector["vquality"] = value
}
if seconds, err := strconv.Atoi(strings.TrimSpace(fmt.Sprint(intent.Options["videoSeconds"]))); err == nil && seconds > 0 {
selector["videoSeconds"] = strconv.Itoa(seconds)
}
if audio := normalizedScalar(intent.Options["videoGenerateAudio"]); audio == "true" || audio == "false" {
selector["videoGenerateAudio"] = audio
}
case "image":
for name, value := range intent.Options {
if value != nil {
selector[name] = fmt.Sprint(value)
}
}
if intent.Inputs["image"] > 0 {
selector["operation"] = "image_to_image"
} else {
selector["operation"] = "text_to_image"
}
rawQuality, _ := intent.Options["quality"].(string)
rawSize, _ := intent.Options["size"].(string)
quality := normalizeImagePriceQuality(rawQuality, "")
if quality != "" {
selector["quality"] = quality
if isImageResolutionTier(quality) {
selector["resolution"] = quality
}
}
if resolution := imageResolutionFromSize(rawSize); resolution != "" {
selector["resolution"] = resolution
// 保留存量 quality=1k/2k/4k 价格档的请求选择器。
if quality == "" || quality == "auto" || quality == "any" {
selector["quality"] = resolution
}
}
if quality := selector["quality"]; quality == "auto" || quality == "any" {
delete(selector, "quality")
}
if value := strings.ToLower(strings.TrimSpace(rawSize)); value != "" && value != "auto" && value != "any" {
selector["size"] = value
}
}
return selector
}
func normalizeImagePriceQuality(rawQuality string, rawSize string) string {
quality := strings.ToLower(strings.TrimSpace(rawQuality))
if quality != "" && quality != "auto" && quality != "any" {
return quality
}
parts := strings.Split(strings.ToLower(strings.TrimSpace(rawSize)), "x")
if len(parts) != 2 {
return ""
}
width, widthErr := strconv.ParseInt(strings.TrimSpace(parts[0]), 10, 64)
height, heightErr := strconv.ParseInt(strings.TrimSpace(parts[1]), 10, 64)
if widthErr != nil || heightErr != nil || width <= 0 || height <= 0 || width > (1<<32)/height {
return ""
}
pixels := width * height
switch {
case pixels <= 2_000_000:
return "1k"
case pixels <= 4_300_000:
return "2k"
case pixels <= 8_294_400:
return "4k"
default:
return ""
}
}
func imageResolutionFromSize(rawSize string) string {
parts := strings.Split(strings.ToLower(strings.TrimSpace(rawSize)), "x")
if len(parts) != 2 {
return ""
}
width, widthErr := strconv.ParseInt(strings.TrimSpace(parts[0]), 10, 64)
height, heightErr := strconv.ParseInt(strings.TrimSpace(parts[1]), 10, 64)
if widthErr != nil || heightErr != nil || width <= 0 || height <= 0 || width > (1<<32)/height {
return ""
}
pixels := width * height
switch {
case pixels <= 2_000_000:
return "1k"
case pixels <= 4_300_000:
return "2k"
case pixels <= 8_294_400:
return "4k"
default:
return ""
}
}
func isImageResolutionTier(value string) bool {
switch strings.ToLower(strings.TrimSpace(value)) {
case "1k", "2k", "4k":
return true
default:
return false
}
}
func skuSelectorForTier(tier model.ChannelModelPriceTier) map[string]string {
selector := model.DecodeSKUSelector(tier.SelectorJSON)
if quality := strings.ToLower(strings.TrimSpace(selector["quality"])); isImageResolutionTier(quality) && strings.TrimSpace(selector["resolution"]) == "" {
selector["resolution"] = quality
delete(selector, "quality")
}
if len(selector) == 0 {
if resolution := normalizeChannelModelTierResolution(tier.Resolution); resolution != "*" {
selector["vquality"] = resolution
}
if tier.VideoSeconds > 0 {
selector["videoSeconds"] = strconv.Itoa(tier.VideoSeconds)
}
}
return selector
}
func matchSKUSelector(tier map[string]string, requested map[string]string) (bool, int) {
score := 0
for key, expected := range tier {
expected = strings.TrimSpace(expected)
if expected == "" || expected == "*" {
continue
}
if requested[key] != expected {
return false, 0
}
score++
}
return true, score
}
func (s *Service) logicalRouteBlocked(route cachedLogicalRoute) bool {
now := time.Now()
keys := []string{"channel:" + route.ChannelModel.ChannelID, "channel-model:" + route.ChannelModel.ID, "route:" + route.Route.ID}
// 这里会删除过期项,必须使用写锁;不要改成 RLock。
s.routeHealthMu.Lock()
for _, key := range keys {
until, exists := s.routeHealthBlocked[key]
if !exists {
continue
}
if !until.After(now) {
delete(s.routeHealthBlocked, key)
continue
}
s.routeHealthMu.Unlock()
return true
}
s.routeHealthMu.Unlock()
if s.coordinator == nil || !s.coordinator.HasRedis() {
return false
}
ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
defer cancel()
for _, key := range keys {
until, err := s.coordinator.RouteBlockedUntil(ctx, key)
if err != nil {
log.Printf("logical route distributed health check failed key=%s: %v", key, err)
return false
}
if until.After(now) {
return true
}
}
return false
}
func mergeIntentDefaults(options map[string]any, defaults map[string]any) map[string]any {
result := make(map[string]any, len(defaults)+len(options))
for key, value := range defaults {
result[key] = value
}
for key, value := range options {
result[key] = value
}
return result
}
func weightedRoute(routes []cachedLogicalRoute) cachedLogicalRoute {
if len(routes) == 1 {
return routes[0]
}
var total int64
for _, route := range routes {
total += int64(route.Route.Weight)
}
if total <= 0 {
return routes[0]
}
var raw [8]byte
if _, err := cryptorand.Read(raw[:]); err != nil {
return routes[0]
}
totalUnsigned := uint64(total)
// 丢弃不能整除 total 的低概率尾部,避免取模造成轻微权重偏差。
threshold := -totalUnsigned % totalUnsigned
randomValue := binary.LittleEndian.Uint64(raw[:])
for randomValue < threshold {
if _, err := cryptorand.Read(raw[:]); err != nil {
return routes[0]
}
randomValue = binary.LittleEndian.Uint64(raw[:])
}
pick := int64(randomValue % totalUnsigned)
for _, route := range routes {
if pick < int64(route.Route.Weight) {
return route
}
pick -= int64(route.Route.Weight)
}
return routes[len(routes)-1]
}
func (s *Service) sortedRouteDiagnostics(routes []cachedLogicalRoute, intent ModelRequestIntent) []RouteSimulationCandidate {
result := make([]RouteSimulationCandidate, 0, len(routes))
poolPriority := math.MinInt
for _, route := range routes {
match := MatchCapability(route.CapabilitySpec, intent)
blocked := s.logicalRouteBlocked(route)
if route.Route.Enabled && route.Route.Weight > 0 && match.Matched && !blocked && route.Route.Priority > poolPriority {
poolPriority = route.Route.Priority
}
result = append(result, RouteSimulationCandidate{RouteID: route.Route.ID, ChannelModelID: route.ChannelModel.ID, ChannelModelKey: route.ChannelModel.ModelKey, ChannelModelName: route.ChannelModel.DisplayName, Priority: route.Route.Priority, Weight: route.Route.Weight, Enabled: route.Route.Enabled, Matched: match.Matched, Blocked: blocked, Reasons: match.Reasons})
}
for index := range result {
result[index].InPool = result[index].Enabled && result[index].Weight > 0 && result[index].Matched && !result[index].Blocked && result[index].Priority == poolPriority
}
sort.SliceStable(result, func(i, j int) bool { return result[i].Priority > result[j].Priority })
return result
}