feat(models): 模型规格价格 - 合并 SKU 价格档与按规格路由能力

This commit is contained in:
ddCat
2026-08-22 17:17:08 +08:00
36 changed files with 3404 additions and 421 deletions

View File

@@ -0,0 +1,675 @@
package main
import (
"encoding/json"
"errors"
"flag"
"fmt"
"log"
"os"
"sort"
"strconv"
"strings"
"time"
"infinite-canvas/backend/internal/database"
"infinite-canvas/backend/internal/model"
"infinite-canvas/backend/internal/repository"
"infinite-canvas/backend/internal/service"
"gorm.io/gorm"
)
// 该迁移把上一轮“每个分辨率一个渠道模型”的视频 SKU 收敛为一个渠道模型的多个价格档。
// 已被家族模型替代的渠道 SKU 会软删除,旧前台 SKU 会归档;任务、路由尝试与账单订单不改写。
var familyCodes = []string{
"seedance-2-5", "seedance-2-0", "seedance-2-0-mini", "seedance-2-0-fast",
"grok-video-1-5", "artdance-2-0", "artdance-2-5", "artdance-2-mini", "artdance-fast",
}
type familyPlan struct {
logicalModel model.LogicalModel
channelModel model.ChannelModel
priceTiers []model.ChannelModelPriceTier
legacyLogicalModelIDs []string
legacyChannelModelIDs []string
alreadySynced bool
creatingLogical bool
}
func main() {
apply := flag.Bool("apply", false, "创建系统模型价格档并切换当前前台目录版本")
flag.Parse()
db, err := database.Open(database.Config{Driver: "postgres", DSN: os.Getenv("DATABASE_URL"), DataDir: os.Getenv("CANVAS_BACKEND_DATA_DIR")})
if err != nil {
log.Fatal(err)
}
if db.Dialector.Name() != "postgres" {
log.Fatal("价格档迁移只允许连接 PostgreSQL")
}
if err := database.ConfigurePool(db); err != nil {
log.Fatal(err)
}
if err := database.MigrateSchema(db); err != nil {
log.Fatal(err)
}
repo := repository.New(db)
plans, err := buildPlans(repo)
if err != nil {
log.Fatal(err)
}
for _, plan := range plans {
tiers := make([]string, 0, len(plan.priceTiers))
for _, tier := range plan.priceTiers {
tiers = append(tiers, fmt.Sprintf("%s=>%s", tier.SelectorKey, tier.ProviderModelKey))
}
log.Printf("%s: 系统模型=%s 价格档=[%s] 已同步=%t,待归档旧前台=%d,待删除旧渠道模型=%d", plan.logicalModel.Code, plan.channelModel.ModelKey, strings.Join(tiers, ", "), plan.alreadySynced, len(plan.legacyLogicalModelIDs), len(plan.legacyChannelModelIDs))
}
if !*apply {
log.Print("dry-run 完成;确认后使用 --apply 写入。旧 SKU 会从配置目录删除,历史任务与账务快照不会改写")
return
}
actor, err := migrationAdmin(db)
if err != nil {
log.Fatal(err)
}
svc := service.New(repo, os.Getenv("CANVAS_BACKEND_DATA_DIR"))
for _, plan := range plans {
// 已规范化且没有遗留 SKU 的家族只参与 dry-run 报告,不能在每次迁移时重发 revision,
// 否则会无意义地改动已经稳定的前台能力和路由快照。
if plan.alreadySynced && !plan.creatingLogical && len(plan.legacyLogicalModelIDs) == 0 && len(plan.legacyChannelModelIDs) == 0 {
log.Printf("跳过已规范化模型家族 %s", plan.logicalModel.Code)
continue
}
if !plan.alreadySynced {
if err := repo.SaveChannelModelWithPriceTiers(&plan.channelModel, plan.priceTiers); err != nil {
log.Fatalf("保存 %s 价格档失败:%v", plan.logicalModel.Code, err)
}
}
logicalModelID := plan.logicalModel.ID
if plan.creatingLogical {
logicalModelID = ""
}
if !plan.alreadySynced || plan.creatingLogical {
if _, err := svc.SaveAdminLogicalModel(actor, logicalModelID, service.LogicalModelRequest{
Code: plan.logicalModel.Code, Name: plan.logicalModel.Name, Icon: plan.logicalModel.Icon, Description: plan.logicalModel.Description,
Capability: plan.channelModel.Capability, Enabled: plan.logicalModel.Enabled, SortOrder: plan.logicalModel.SortOrder,
LegacyModelIDs: append(decodeLegacyModelIDs(plan.logicalModel.LegacyModelIDsJSON), plan.legacyLogicalModelIDs...), SourceChannelModelID: plan.channelModel.ID,
}); err != nil {
log.Fatalf("同步前台模型 %s 失败:%v", plan.logicalModel.Code, err)
}
}
if err := deleteLegacySKURecords(db, plan); err != nil {
log.Fatalf("删除 %s 的旧 SKU 失败:%v", plan.logicalModel.Code, err)
}
log.Printf("已同步 %s 到系统模型 %s", plan.logicalModel.Code, plan.channelModel.ModelKey)
}
log.Print("迁移完成:新任务将按系统模型规格价格档结算;旧 SKU 已从配置目录删除,历史任务与账务快照保持不变")
}
func buildPlans(repo *repository.Repository) ([]familyPlan, error) {
plans := make([]familyPlan, 0, len(familyCodes))
for _, code := range familyCodes {
items, err := repo.LogicalModels(true)
if err != nil {
return nil, err
}
var logicalModel *model.LogicalModel
for index := range items {
if items[index].Code == code {
logicalModel = &items[index]
break
}
}
if logicalModel == nil {
return nil, fmt.Errorf("缺少前台模型家族 %s", code)
}
graph, err := repo.LogicalModelGraph(logicalModel.ID, true)
if err != nil {
return nil, fmt.Errorf("读取前台模型 %s:%w", code, err)
}
if graph.Revision == nil || len(graph.Routes) == 0 || len(graph.ChannelModels) == 0 {
return nil, fmt.Errorf("前台模型 %s 没有当前供应线路", code)
}
if logicalModel.SourceChannelModelID != "" {
source, sourceErr := repo.ChannelModel(logicalModel.SourceChannelModelID)
if sourceErr != nil {
return nil, fmt.Errorf("读取已同步模型 %s 的系统源:%w", code, sourceErr)
}
legacyIDs, legacyErr := legacyChannelModelIDsForFamily(repo, *source, logicalModel.Code)
if legacyErr != nil {
return nil, fmt.Errorf("识别 %s 的旧渠道 SKU:%w", logicalModel.Code, legacyErr)
}
plans = append(plans, familyPlan{logicalModel: *logicalModel, channelModel: *source, priceTiers: source.PriceTiers, legacyChannelModelIDs: legacyIDs, alreadySynced: true})
continue
}
plan, planErr := buildFamilyPlan(repo, *logicalModel, graph)
if planErr != nil {
return nil, planErr
}
plans = append(plans, plan)
}
imagePlan, imageErr := buildImage2Plan(repo)
if imageErr != nil {
return nil, imageErr
}
if imagePlan != nil {
plans = append(plans, *imagePlan)
}
return plans, nil
}
// buildImage2Plan merges the three historical 1K/2K/4K records into one system
// model. The exact upstream key remains tier-local so existing channel behaviour
// is retained while the selected quality becomes a request parameter.
func buildImage2Plan(repo *repository.Repository) (*familyPlan, error) {
all, err := repo.LogicalModels(true)
if err != nil {
return nil, err
}
byCode := make(map[string]model.LogicalModel, len(all))
for _, item := range all {
byCode[item.Code] = item
}
var existing *model.LogicalModel
if item, found := byCode["image-2"]; found {
existing = &item
}
var canonical *model.ChannelModel
if existing != nil && existing.SourceChannelModelID != "" {
source, sourceErr := repo.ChannelModel(existing.SourceChannelModelID)
if sourceErr != nil {
return nil, sourceErr
}
canonical = source
}
legacyCodes := []string{"image-2-1k", "image-2-2k", "image-2-4k"}
legacyModels := make([]model.LogicalModel, 0, len(legacyCodes))
legacyChannelModels := make([]model.ChannelModel, 0, len(legacyCodes))
for _, code := range legacyCodes {
logicalModel, found := byCode[code]
if !found {
continue
}
graph, graphErr := repo.LogicalModelGraph(logicalModel.ID, true)
if graphErr != nil || graph.Revision == nil || len(graph.Routes) != 1 || len(graph.ChannelModels) != 1 {
return nil, fmt.Errorf("读取 GPT Image 2 旧模型 %s 的供应线路失败", code)
}
legacyModels = append(legacyModels, logicalModel)
legacyChannelModels = append(legacyChannelModels, graph.ChannelModels[0])
}
if len(legacyModels) == 0 {
if existing == nil || canonical == nil {
return nil, errors.New("缺少 GPT Image 2 的系统模型和旧 SKU,无法判断迁移状态")
}
return &familyPlan{logicalModel: *existing, channelModel: *canonical, priceTiers: canonical.PriceTiers, alreadySynced: true}, nil
}
if len(legacyModels) != len(legacyCodes) {
return nil, errors.New("GPT Image 2 的旧 SKU 记录不完整,拒绝自动删除")
}
channelID := legacyChannelModels[0].ChannelID
for _, item := range legacyChannelModels {
if item.ChannelID != channelID || item.Capability != "image" || item.Protocol != legacyChannelModels[0].Protocol || item.CapabilityConfigJSON != legacyChannelModels[0].CapabilityConfigJSON {
return nil, errors.New("GPT Image 2 的旧 SKU 渠道、协议或能力配置不一致,拒绝自动合并")
}
}
if canonical == nil {
found, canonicalErr := repo.ChannelModelByKeyIncludingDisabled(channelID, "gpt-image-2")
if canonicalErr != nil && !errors.Is(canonicalErr, gorm.ErrRecordNotFound) {
return nil, canonicalErr
}
canonical = found
} else if canonical.ChannelID != channelID {
return nil, errors.New("GPT Image 2 的新旧模型不属于同一系统渠道,拒绝自动删除")
}
channelModel := model.ChannelModel{}
alreadySynced := canonical != nil && len(canonical.PriceTiers) > 0
var tiers []model.ChannelModelPriceTier
if canonical != nil {
channelModel = *canonical
tiers = canonical.PriceTiers
} else {
var tierErr error
tiers, tierErr = image2PriceTiersFromLegacy(repo, legacyChannelModels)
if tierErr != nil {
return nil, tierErr
}
modelID, idErr := repo.NextPrefixedID("MODEL")
if idErr != nil {
return nil, idErr
}
channelModel = model.ChannelModel{
ID: modelID, ChannelID: channelID, ModelKey: "gpt-image-2", ProviderModelKey: "gpt-image-2", DisplayName: "GPT Image 2",
Capability: "image", Protocol: legacyChannelModels[0].Protocol, Enabled: true, PriceConfigured: true, PriceVersion: 1,
CapabilityConfigJSON: legacyChannelModels[0].CapabilityConfigJSON, CapabilityVersion: 1,
}
}
if len(tiers) == 0 {
var tierErr error
tiers, tierErr = image2PriceTiersFromLegacy(repo, legacyChannelModels)
if tierErr != nil {
return nil, tierErr
}
}
channelModel.PriceTiers = tiers
applyPriceSummary(&channelModel, tiers)
logicalModel := model.LogicalModel{Code: "image-2", Name: "GPT Image 2", Description: "GPT Image 2", Capability: "image", Enabled: true, SortOrder: legacyModels[0].SortOrder}
creatingLogical := true
if existing != nil {
logicalModel = *existing
creatingLogical = false
}
plan := &familyPlan{logicalModel: logicalModel, channelModel: channelModel, priceTiers: tiers, alreadySynced: alreadySynced, creatingLogical: creatingLogical}
for index := range legacyModels {
plan.legacyLogicalModelIDs = append(plan.legacyLogicalModelIDs, legacyModels[index].ID)
plan.legacyChannelModelIDs = append(plan.legacyChannelModelIDs, legacyChannelModels[index].ID)
}
return plan, nil
}
func image2PriceTiersFromLegacy(repo *repository.Repository, items []model.ChannelModel) ([]model.ChannelModelPriceTier, error) {
result := make([]model.ChannelModelPriceTier, 0, len(items))
seen := make(map[string]bool, len(items))
for _, item := range items {
quality := ""
for _, candidate := range []string{"1k", "2k", "4k"} {
if strings.HasSuffix(strings.ToLower(item.ModelKey), "-"+candidate) {
quality = candidate
}
}
if quality == "" || seen[quality] {
return nil, fmt.Errorf("无法从旧渠道模型 %s 推导唯一的 GPT Image 2 质量档", item.ModelKey)
}
seen[quality] = true
tier := firstActiveTier(item)
id, err := repo.NextPrefixedID("PTIER")
if err != nil {
return nil, err
}
selector, key, err := model.CanonicalSKUSelector(map[string]string{"quality": quality})
if err != nil {
return nil, err
}
tier.ID, tier.ChannelModelID, tier.Selector, tier.SelectorKey, tier.SelectorJSON = id, "", selector, key, key
tier.Resolution, tier.VideoSeconds = "*", 0
tier.ProviderModelKey = firstNonEmpty(tier.ProviderModelKey, item.ProviderModelKey, "gpt-image-2")
tier.PriceVersion = 1
result = append(result, tier)
}
if len(result) != 3 {
return nil, errors.New("GPT Image 2 缺少 1K、2K 或 4K 价格档")
}
return result, nil
}
func deleteLegacySKURecords(db *gorm.DB, plan familyPlan) error {
if len(plan.legacyChannelModelIDs) == 0 && len(plan.legacyLogicalModelIDs) == 0 {
return nil
}
return db.Transaction(func(tx *gorm.DB) error {
var activeChannelTasks int64
if len(plan.legacyChannelModelIDs) > 0 {
if err := tx.Model(&model.Task{}).Where("channel_model_id IN ? AND status IN ?", plan.legacyChannelModelIDs, []model.TaskStatus{model.TaskStatusQueued, model.TaskStatusRunning}).Count(&activeChannelTasks).Error; err != nil {
return err
}
}
var activeLogicalTasks int64
if len(plan.legacyLogicalModelIDs) > 0 {
if err := tx.Model(&model.Task{}).Where("logical_model_id IN ? AND status IN ?", plan.legacyLogicalModelIDs, []model.TaskStatus{model.TaskStatusQueued, model.TaskStatusRunning}).Count(&activeLogicalTasks).Error; err != nil {
return err
}
}
active := activeChannelTasks + activeLogicalTasks
if active > 0 {
return fmt.Errorf("仍有 %d 个排队或运行中的任务引用旧 SKU,稍后重试迁移", active)
}
now := time.Now()
if len(plan.legacyLogicalModelIDs) > 0 {
if err := tx.Model(&model.LogicalModel{}).Where("id IN ? AND archived_at IS NULL", plan.legacyLogicalModelIDs).Updates(map[string]any{"enabled": false, "archived_at": now, "updated_at": now}).Error; err != nil {
return err
}
}
if len(plan.legacyChannelModelIDs) == 0 {
return nil
}
var legacyModels []model.ChannelModel
if err := tx.Where("id IN ?", plan.legacyChannelModelIDs).Find(&legacyModels).Error; err != nil {
return err
}
var channelIDs []string
if err := tx.Model(&model.ChannelModel{}).Distinct("channel_id").Where("id IN ?", plan.legacyChannelModelIDs).Pluck("channel_id", &channelIDs).Error; err != nil {
return err
}
if err := tx.Where("channel_model_id IN ?", plan.legacyChannelModelIDs).Delete(&model.ChannelModelPriceTier{}).Error; err != nil {
return err
}
if err := tx.Where("id IN ?", plan.legacyChannelModelIDs).Delete(&model.ChannelModel{}).Error; err != nil {
return err
}
for _, channelID := range channelIDs {
var channel model.ModelChannel
if err := tx.First(&channel, "id = ?", channelID).Error; err != nil {
return err
}
var activeModels []model.ChannelModel
if err := tx.Where("channel_id = ? AND enabled = ?", channelID, true).Order("created_at asc").Find(&activeModels).Error; err != nil {
return err
}
modelKeys := make([]string, 0, len(activeModels))
for _, item := range activeModels {
modelKeys = append(modelKeys, item.ModelKey)
}
encoded, err := json.Marshal(modelKeys)
if err != nil {
return err
}
retiredKeys := decodeRetiredModelKeys(channel.RetiredModelsJSON)
for _, legacy := range legacyModels {
if legacy.ChannelID == channelID {
retiredKeys[channelModelCatalogKey(legacy.ModelKey)] = legacy.ModelKey
}
}
retiredModels := make([]string, 0, len(retiredKeys))
for _, modelKey := range retiredKeys {
retiredModels = append(retiredModels, modelKey)
}
sort.Strings(retiredModels)
retiredJSON, err := json.Marshal(retiredModels)
if err != nil {
return err
}
if err := tx.Model(&model.ModelChannel{}).Where("id = ?", channelID).Updates(map[string]any{"models_json": string(encoded), "retired_models_json": string(retiredJSON), "updated_at": now}).Error; err != nil {
return err
}
}
return nil
})
}
func buildFamilyPlan(repo *repository.Repository, logicalModel model.LogicalModel, graph *repository.LogicalModelGraph) (familyPlan, error) {
active := make([]model.ChannelModel, 0, len(graph.Routes))
byID := make(map[string]model.ChannelModel, len(graph.ChannelModels))
for _, item := range graph.ChannelModels {
byID[item.ID] = item
}
for _, route := range graph.Routes {
if route.Enabled && route.Weight > 0 {
if item, found := byID[route.ChannelModelID]; found {
active = append(active, item)
}
}
}
if len(active) == 0 {
return familyPlan{}, fmt.Errorf("前台模型 %s 没有启用供应线路", logicalModel.Code)
}
channelID := active[0].ChannelID
for _, item := range active {
if item.ChannelID != channelID || item.Capability != "video" {
return familyPlan{}, fmt.Errorf("前台模型 %s 的供应线路不属于同一视频系统渠道,需人工处理", logicalModel.Code)
}
}
canonical, err := repo.ChannelModelByKeyIncludingDisabled(channelID, logicalModel.Code)
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return familyPlan{}, err
}
if canonical != nil {
legacyIDs, legacyErr := legacyChannelModelIDsForFamily(repo, *canonical, logicalModel.Code)
if legacyErr != nil {
return familyPlan{}, legacyErr
}
return familyPlan{logicalModel: logicalModel, channelModel: *canonical, priceTiers: canonical.PriceTiers, legacyChannelModelIDs: legacyIDs, alreadySynced: true}, nil
}
configJSON, err := mergedVideoCapabilityConfig(active)
if err != nil {
return familyPlan{}, fmt.Errorf("合并 %s 视频能力:%w", logicalModel.Code, err)
}
modelID, err := repo.NextPrefixedID("MODEL")
if err != nil {
return familyPlan{}, err
}
tiers, err := priceTiersFromLegacy(repo, active)
if err != nil {
return familyPlan{}, fmt.Errorf("读取 %s 旧 SKU 价格:%w", logicalModel.Code, err)
}
channelModel := model.ChannelModel{
ID: modelID, ChannelID: channelID, ModelKey: logicalModel.Code, ProviderModelKey: active[0].ProviderModelKey,
DisplayName: logicalModel.Name, Capability: "video", Protocol: active[0].Protocol, Enabled: true,
PriceConfigured: len(tiers) > 0, PriceVersion: 1, CapabilityConfigJSON: configJSON, CapabilityVersion: 1,
}
applyPriceSummary(&channelModel, tiers)
return familyPlan{logicalModel: logicalModel, channelModel: channelModel, priceTiers: tiers}, nil
}
// legacyChannelModelIDsForFamily 只识别与已规范模型同渠道、同能力、同协议的历史 SKU。
// 这里宁可漏掉无法确认归属的模型,也不能删除独立产品模型。
func legacyChannelModelIDsForFamily(repo *repository.Repository, canonical model.ChannelModel, familyCode string) ([]string, error) {
items, err := repo.ChannelModels(canonical.ChannelID, true)
if err != nil {
return nil, err
}
aliases := familyModelKeyAliases(familyCode)
ids := make([]string, 0)
for _, item := range items {
if item.ID == canonical.ID || item.Capability != canonical.Capability || item.Protocol != canonical.Protocol {
continue
}
key := normalizedModelKey(item.ModelKey)
for _, alias := range aliases {
if key == alias || (strings.HasPrefix(key, alias+"-") && isLegacySKUVariant(strings.TrimPrefix(key, alias+"-"))) {
ids = append(ids, item.ID)
break
}
}
}
sort.Strings(ids)
return ids, nil
}
func isLegacySKUVariant(value string) bool {
for _, resolution := range []string{"480p", "720p", "1080p", "1440p", "2160p"} {
if value == resolution {
return true
}
}
return false
}
func familyModelKeyAliases(familyCode string) []string {
canonical := normalizedModelKey(familyCode)
aliases := []string{canonical, "doubao-" + canonical}
if familyCode == "grok-video-1-5" {
aliases = append(aliases, "grok-imagine-video-1-5")
}
return aliases
}
func normalizedModelKey(value string) string {
value = strings.ToLower(strings.TrimSpace(value))
var builder strings.Builder
lastDash := false
for _, char := range value {
if (char >= 'a' && char <= 'z') || (char >= '0' && char <= '9') {
builder.WriteRune(char)
lastDash = false
continue
}
if !lastDash {
builder.WriteByte('-')
lastDash = true
}
}
return strings.Trim(builder.String(), "-")
}
func channelModelCatalogKey(value string) string {
return strings.ToLower(strings.TrimPrefix(strings.TrimSpace(value), "models/"))
}
func decodeRetiredModelKeys(raw string) map[string]string {
var values []string
_ = json.Unmarshal([]byte(raw), &values)
result := make(map[string]string, len(values))
for _, value := range values {
if key := channelModelCatalogKey(value); key != "" {
result[key] = value
}
}
return result
}
func mergedVideoCapabilityConfig(items []model.ChannelModel) (string, error) {
config, err := service.DecodeModelCapabilityConfig(items[0].CapabilityConfigJSON)
if err != nil || config == nil || config.Video == nil {
return "", errors.New("首个旧 SKU 缺少视频能力配置")
}
resolutions := make(map[string]bool)
durations := make(map[int]bool)
for _, item := range items {
candidate, decodeErr := service.DecodeModelCapabilityConfig(item.CapabilityConfigJSON)
if decodeErr != nil || candidate == nil || candidate.Video == nil {
return "", fmt.Errorf("渠道模型 %s 的视频能力无效", item.ModelKey)
}
for _, value := range candidate.Video.Resolutions {
resolutions[value] = true
}
for _, value := range candidate.Video.Duration.Values {
durations[value] = true
}
}
config.Video.Resolutions = sortedStrings(resolutions)
if len(durations) > 0 {
config.Video.Duration.Selection = "enum"
config.Video.Duration.Values = sortedInts(durations)
config.Video.Duration.Default = config.Video.Duration.Values[0]
}
encoded, err := json.Marshal(config)
if err != nil {
return "", err
}
return string(encoded), nil
}
func priceTiersFromLegacy(repo *repository.Repository, items []model.ChannelModel) ([]model.ChannelModelPriceTier, error) {
result := make([]model.ChannelModelPriceTier, 0, len(items))
seen := make(map[string]bool, len(items))
for _, item := range items {
resolution, seconds := legacyTierDimensions(item)
key := resolution + fmt.Sprintf(":%d", seconds)
if seen[key] {
return nil, fmt.Errorf("旧 SKU 在规格 %s 上重复,无法决定唯一价格", key)
}
seen[key] = true
tier := firstActiveTier(item)
id, err := repo.NextPrefixedID("PTIER")
if err != nil {
return nil, err
}
selectorInput := map[string]string{}
if resolution != "*" {
selectorInput["vquality"] = resolution
}
if seconds > 0 {
selectorInput["videoSeconds"] = strconv.Itoa(seconds)
}
selector, selectorKey, selectorErr := model.CanonicalSKUSelector(selectorInput)
if selectorErr != nil {
return nil, selectorErr
}
tier.ID, tier.ChannelModelID, tier.Selector, tier.SelectorKey, tier.SelectorJSON, tier.Resolution, tier.VideoSeconds = id, "", selector, selectorKey, selectorKey, resolution, seconds
tier.ProviderModelKey = firstNonEmpty(tier.ProviderModelKey, item.ProviderModelKey, item.ModelKey)
tier.PriceVersion = 1
result = append(result, tier)
}
return result, nil
}
func legacyTierDimensions(item model.ChannelModel) (string, int) {
resolution := "*"
seconds := 0
config, _ := service.DecodeModelCapabilityConfig(item.CapabilityConfigJSON)
if config != nil && config.Video != nil {
if len(config.Video.Resolutions) == 1 {
resolution = strings.ToLower(config.Video.Resolutions[0])
}
if len(config.Video.Duration.Values) == 1 {
seconds = config.Video.Duration.Values[0]
}
}
for _, candidate := range []string{"480p", "720p", "1080p", "1440p", "2160p"} {
if strings.Contains(strings.ToLower(item.ModelKey), candidate) {
resolution = candidate
}
}
return resolution, seconds
}
func firstActiveTier(item model.ChannelModel) model.ChannelModelPriceTier {
for _, tier := range item.PriceTiers {
if tier.Enabled && tier.PriceConfigured {
return tier
}
}
return model.ChannelModelPriceTier{ProviderModelKey: item.ProviderModelKey, BillingMode: item.BillingMode, UnitPriceMicrocredits: item.UnitPriceMicrocredits, InputTokenPriceMicrocredits: item.InputTokenPriceMicrocredits, OutputTokenPriceMicrocredits: item.OutputTokenPriceMicrocredits, CachedTokenPriceMicrocredits: item.CachedTokenPriceMicrocredits, PriceConfigured: item.PriceConfigured, Enabled: item.Enabled}
}
func applyPriceSummary(channelModel *model.ChannelModel, tiers []model.ChannelModelPriceTier) {
if len(tiers) == 0 {
return
}
tier := tiers[0]
channelModel.BillingMode = tier.BillingMode
channelModel.UnitPriceMicrocredits = tier.UnitPriceMicrocredits
channelModel.InputTokenPriceMicrocredits = tier.InputTokenPriceMicrocredits
channelModel.OutputTokenPriceMicrocredits = tier.OutputTokenPriceMicrocredits
channelModel.CachedTokenPriceMicrocredits = tier.CachedTokenPriceMicrocredits
channelModel.PriceConfigured = false
for _, candidate := range tiers {
if candidate.Enabled && candidate.PriceConfigured {
channelModel.PriceConfigured = true
break
}
}
}
func migrationAdmin(db *gorm.DB) (*model.User, error) {
var actor model.User
if err := db.Where("role = ?", model.UserRoleAdmin).Order("created_at asc").First(&actor).Error; err != nil {
return nil, fmt.Errorf("需要现有管理员作为迁移审计主体:%w", err)
}
return &actor, nil
}
func decodeLegacyModelIDs(raw string) []string {
var ids []string
_ = json.Unmarshal([]byte(raw), &ids)
return ids
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return value
}
}
return ""
}
func sortedStrings(values map[string]bool) []string {
result := make([]string, 0, len(values))
for value := range values {
result = append(result, value)
}
sort.Strings(result)
return result
}
func sortedInts(values map[int]bool) []int {
result := make([]int, 0, len(values))
for value := range values {
result = append(result, value)
}
sort.Ints(result)
return result
}

View File

@@ -0,0 +1,468 @@
package main
import (
"encoding/json"
"errors"
"flag"
"fmt"
"log"
"math"
"os"
"sort"
"strconv"
"strings"
"infinite-canvas/backend/internal/database"
"infinite-canvas/backend/internal/model"
"infinite-canvas/backend/internal/repository"
"infinite-canvas/backend/internal/service"
"gorm.io/gorm"
)
// 迁移只重建创作端目录。旧模型、revision、route 以及全部任务和账务快照都保留,
// 这样历史任务恢复或重试时仍会使用它创建时绑定的供应线路。
type familyDefinition struct {
Code string
Name string
Description string
OldCodes []string
}
type familyRoute struct {
channelModel model.ChannelModel
spec service.CapabilitySpec
}
type familyPlan struct {
definition familyDefinition
oldModels []model.LogicalModel
routes []familyRoute
request service.LogicalModelRequest
exists bool
}
var familyDefinitions = []familyDefinition{
{Code: "seedance-2-5", Name: "Seedance 2.5", Description: "Seedance 2.5 视频生成,按所选分辨率自动路由并结算", OldCodes: []string{"doubao-seedance-2-5-480p", "doubao-seedance-2-5", "doubao-seedance-2-5-1080p"}},
{Code: "seedance-2-0", Name: "Seedance 2.0", Description: "Seedance 2.0 视频生成,按所选分辨率自动路由并结算", OldCodes: []string{"doubao-seedance-2-0-480p", "doubao-seedance-2-0", "doubao-seedance-2-0-1080p"}},
{Code: "seedance-2-0-mini", Name: "Seedance 2.0 Mini", Description: "Seedance 2.0 Mini 视频生成,按所选分辨率自动路由并结算", OldCodes: []string{"doubao-seedance-2-0-mini-480p", "doubao-seedance-2-0-mini"}},
{Code: "seedance-2-0-fast", Name: "Seedance 2.0 Fast", Description: "Seedance 2.0 Fast 视频生成,按所选分辨率自动路由并结算", OldCodes: []string{"doubao-seedance-2-0-fast-480p", "doubao-seedance-2-0-fast"}},
{Code: "grok-video-1-5", Name: "Grok Imagine Video 1.5", Description: "Grok Imagine Video 1.5,按所选分辨率自动路由并结算", OldCodes: []string{"grok-video-1.5-480p", "grok-video-1.5-720p", "grok-imagine-video-1.5-1080p"}},
{Code: "artdance-2-0", Name: "Artdance 2.0", Description: "Artdance 2.0 视频生成,按所选分辨率自动路由并结算", OldCodes: []string{"artdance-2-0-480p", "artdance-2-0-720p", "artdance-2-0-1080p"}},
{Code: "artdance-2-5", Name: "Artdance 2.5", Description: "Artdance 2.5 视频生成,按所选分辨率自动路由并结算", OldCodes: []string{"artdance-2-5-480p", "artdance-2-5-720p"}},
{Code: "artdance-2-mini", Name: "Artdance 2 Mini", Description: "Artdance 2 Mini 视频生成,按所选分辨率自动路由并结算", OldCodes: []string{"artdance-2-mini-480p", "artdance-2-mini-720p"}},
{Code: "artdance-fast", Name: "Artdance Fast", Description: "Artdance Fast 视频生成,按所选分辨率自动路由并结算", OldCodes: []string{"artdance-fast-480p", "artdance-fast-720p"}},
}
func main() {
apply := flag.Bool("apply", false, "写入模型家族并归档旧 SKU 目录项")
flag.Parse()
db, err := database.Open(database.Config{
Driver: "postgres",
DSN: os.Getenv("DATABASE_URL"),
DataDir: os.Getenv("CANVAS_BACKEND_DATA_DIR"),
})
if err != nil {
log.Fatal(err)
}
if db.Dialector.Name() != "postgres" {
log.Fatal("模型家族迁移只允许连接 PostgreSQL")
}
if err := database.ConfigurePool(db); err != nil {
log.Fatal(err)
}
if err := database.MigrateSchema(db); err != nil {
log.Fatal(err)
}
repo := repository.New(db)
plans, err := buildPlans(repo, db)
if err != nil {
log.Fatal(err)
}
printPlans(plans)
if err := ensureNoActiveTasks(db, plans); err != nil {
log.Fatal(err)
}
if !*apply {
log.Print("dry-run 完成;确认无误后传入 --apply 执行写入")
return
}
actor, err := migrationAdmin(db)
if err != nil {
log.Fatal(err)
}
svc := service.New(repo, os.Getenv("CANVAS_BACKEND_DATA_DIR"))
for _, plan := range plans {
if plan.exists {
log.Printf("模型家族已存在,跳过创建:%s", plan.definition.Code)
continue
}
if _, err := svc.SaveAdminLogicalModel(actor, "", plan.request); err != nil {
log.Fatalf("创建模型家族 %s 失败:%v", plan.definition.Code, err)
}
log.Printf("已创建模型家族:%s", plan.definition.Code)
}
if err := verifyPlans(db, svc, plans); err != nil {
log.Fatal(err)
}
for _, plan := range plans {
for _, old := range plan.oldModels {
if old.ArchivedAt != nil {
continue
}
if err := svc.DeleteAdminLogicalModel(actor, old.ID); err != nil {
log.Fatalf("归档旧模型 %s 失败:%v", old.Code, err)
}
log.Printf("已归档旧 SKU 目录项:%s", old.Code)
}
}
log.Print("模型家族迁移完成;历史任务、账务和路由快照均未改写")
}
func buildPlans(repo *repository.Repository, db *gorm.DB) ([]familyPlan, error) {
var models []model.LogicalModel
if err := db.Order("sort_order asc, created_at asc").Find(&models).Error; err != nil {
return nil, err
}
graphs, err := repo.LogicalModelGraphs(models, true)
if err != nil {
return nil, err
}
modelByCode := make(map[string]model.LogicalModel, len(models))
for _, item := range models {
modelByCode[item.Code] = item
}
plans := make([]familyPlan, 0, len(familyDefinitions))
for _, definition := range familyDefinitions {
plan, planErr := buildFamilyPlan(definition, modelByCode, graphs)
if planErr != nil {
return nil, planErr
}
var existing model.LogicalModel
err := db.Where("code = ? AND archived_at IS NULL", definition.Code).First(&existing).Error
if err == nil {
plan.exists = true
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, err
}
plans = append(plans, plan)
}
return plans, nil
}
func buildFamilyPlan(definition familyDefinition, modelByCode map[string]model.LogicalModel, graphs map[string]*repository.LogicalModelGraph) (familyPlan, error) {
plan := familyPlan{definition: definition}
routeSpecs := make([]service.CapabilitySpec, 0, len(definition.OldCodes))
defaults := make([]map[string]any, 0, len(definition.OldCodes))
legacyIDs := make([]string, 0, len(definition.OldCodes))
sortOrder := math.MaxInt
icon := ""
for _, code := range definition.OldCodes {
item, found := modelByCode[code]
if !found {
return plan, fmt.Errorf("模型家族 %s 缺少旧 SKU 模型:%s", definition.Code, code)
}
graph := graphs[item.ID]
if graph == nil || graph.Revision == nil {
return plan, fmt.Errorf("旧 SKU 模型 %s 缺少当前 revision", code)
}
route, channelModel, err := onlyEnabledRoute(graph)
if err != nil {
return plan, fmt.Errorf("旧 SKU 模型 %s:%w", code, err)
}
if route.ChannelModelID != channelModel.ID {
return plan, fmt.Errorf("旧 SKU 模型 %s 的供应线路快照不一致", code)
}
capabilityConfig, err := service.DecodeModelCapabilityConfig(channelModel.CapabilityConfigJSON)
if err != nil {
return plan, fmt.Errorf("读取渠道模型 %s 能力失败:%w", channelModel.ModelKey, err)
}
spec, err := service.CapabilitySpecFromModelCapabilityConfig(capabilityConfig, channelModel.Capability)
if err != nil {
return plan, err
}
if spec.Capability != "video" || channelModel.Capability != "video" {
return plan, fmt.Errorf("旧 SKU 模型 %s 不是视频模型", code)
}
oldDefaults := map[string]any{}
if err := json.Unmarshal([]byte(graph.Revision.DefaultOptionsJSON), &oldDefaults); err != nil {
return plan, fmt.Errorf("读取旧 SKU 模型 %s 默认参数失败:%w", code, err)
}
plan.oldModels = append(plan.oldModels, item)
plan.routes = append(plan.routes, familyRoute{channelModel: channelModel, spec: spec})
routeSpecs = append(routeSpecs, spec)
defaults = append(defaults, oldDefaults)
legacyIDs = append(legacyIDs, item.ID)
if item.SortOrder < sortOrder {
sortOrder = item.SortOrder
}
if icon == "" {
icon = item.Icon
}
}
productSpec, err := unionCapabilitySpecs(routeSpecs)
if err != nil {
return plan, fmt.Errorf("合并模型家族 %s 能力失败:%w", definition.Code, err)
}
if sortOrder == math.MaxInt {
sortOrder = 0
}
plan.request = service.LogicalModelRequest{
Code: definition.Code,
Name: definition.Name,
Icon: icon,
Description: definition.Description,
Capability: "video",
Enabled: true,
SortOrder: sortOrder,
PricePolicy: "channel",
BillingMode: "fixed_request",
LegacyModelIDs: legacyIDs,
CapabilitySpec: productSpec,
DefaultOptions: familyDefaultOptions(productSpec, defaults),
Routes: make([]service.LogicalRouteRequest, 0, len(plan.routes)),
}
for _, route := range plan.routes {
plan.request.Routes = append(plan.request.Routes, service.LogicalRouteRequest{ChannelModelID: route.channelModel.ID, Enabled: true, Weight: 1})
}
return plan, nil
}
func onlyEnabledRoute(graph *repository.LogicalModelGraph) (model.LogicalModelRoute, model.ChannelModel, error) {
routes := make([]model.LogicalModelRoute, 0, len(graph.Routes))
for _, route := range graph.Routes {
if route.Enabled && route.Weight > 0 {
routes = append(routes, route)
}
}
if len(routes) != 1 {
return model.LogicalModelRoute{}, model.ChannelModel{}, fmt.Errorf("需要恰好一条启用供应线路,当前为 %d 条", len(routes))
}
for _, channelModel := range graph.ChannelModels {
if channelModel.ID == routes[0].ChannelModelID {
return routes[0], channelModel, nil
}
}
return model.LogicalModelRoute{}, model.ChannelModel{}, errors.New("启用供应线路引用的渠道模型不存在")
}
func unionCapabilitySpecs(specs []service.CapabilitySpec) (service.CapabilitySpec, error) {
if len(specs) == 0 {
return service.CapabilitySpec{}, errors.New("没有可合并的供应线路能力")
}
result := service.CapabilitySpec{Version: 1, Capability: specs[0].Capability, Inputs: map[string]service.InputConstraint{}, Options: map[string]service.OptionConstraint{}}
operations := make(map[string]bool)
for _, spec := range specs {
if spec.Version != 1 || spec.Capability != result.Capability {
return result, errors.New("供应线路能力类型不一致")
}
for _, operation := range spec.Operations {
operations[operation] = true
}
for name, constraint := range spec.Inputs {
current, exists := result.Inputs[name]
if !exists {
result.Inputs[name] = constraint
continue
}
result.Inputs[name] = service.InputConstraint{Min: min(current.Min, constraint.Min), Max: max(current.Max, constraint.Max)}
}
for name, constraint := range spec.Options {
values, valuesErr := optionValues(constraint)
if valuesErr != nil {
return result, fmt.Errorf("参数 %s:%w", name, valuesErr)
}
current := result.Options[name]
current.Values = appendUniqueValues(current.Values, values...)
current.Min, current.Max, current.Step = nil, nil, nil
result.Options[name] = current
}
}
for operation := range operations {
result.Operations = append(result.Operations, operation)
}
sort.Strings(result.Operations)
for name, constraint := range result.Options {
sort.SliceStable(constraint.Values, func(i, j int) bool {
return optionValueSortKey(constraint.Values[i]) < optionValueSortKey(constraint.Values[j])
})
result.Options[name] = constraint
}
return service.NormalizeCapabilitySpec(result)
}
func optionValues(constraint service.OptionConstraint) ([]any, error) {
if len(constraint.Values) > 0 {
return append([]any(nil), constraint.Values...), nil
}
if constraint.Min == nil || constraint.Max == nil || constraint.Step == nil || *constraint.Step <= 0 {
return nil, errors.New("无法展开数值范围")
}
count := int(math.Floor((*constraint.Max-*constraint.Min)/(*constraint.Step)+1e-9)) + 1
if count < 1 || count > 1000 {
return nil, errors.New("数值范围过大,拒绝自动合并")
}
values := make([]any, 0, count)
for index := 0; index < count; index++ {
values = append(values, *constraint.Min+float64(index)*(*constraint.Step))
}
return values, nil
}
func appendUniqueValues(values []any, additions ...any) []any {
seen := make(map[string]bool, len(values)+len(additions))
for _, value := range values {
seen[optionValueSortKey(value)] = true
}
for _, value := range additions {
key := optionValueSortKey(value)
if seen[key] {
continue
}
seen[key] = true
values = append(values, value)
}
return values
}
func optionValueSortKey(value any) string {
return fmt.Sprintf("%T:%v", value, value)
}
func familyDefaultOptions(spec service.CapabilitySpec, previous []map[string]any) map[string]any {
defaults := make(map[string]any, len(spec.Options))
for name, constraint := range spec.Options {
if name == "vquality" {
if value, found := optionValueByText(constraint.Values, "720p"); found {
defaults[name] = value
continue
}
}
for _, candidate := range previous {
if value, found := candidate[name]; found && optionContains(constraint, value) {
defaults[name] = value
break
}
}
if _, found := defaults[name]; found {
continue
}
if len(constraint.Values) > 0 {
defaults[name] = constraint.Values[0]
}
}
return defaults
}
func optionContains(constraint service.OptionConstraint, value any) bool {
for _, candidate := range constraint.Values {
if strings.EqualFold(fmt.Sprint(candidate), fmt.Sprint(value)) {
return true
}
candidateNumber, candidateErr := strconv.ParseFloat(fmt.Sprint(candidate), 64)
valueNumber, valueErr := strconv.ParseFloat(fmt.Sprint(value), 64)
if candidateErr == nil && valueErr == nil && math.Abs(candidateNumber-valueNumber) < 1e-9 {
return true
}
}
return false
}
func optionValueByText(values []any, target string) (any, bool) {
for _, value := range values {
if strings.EqualFold(fmt.Sprint(value), target) {
return value, true
}
}
return nil, false
}
func ensureNoActiveTasks(db *gorm.DB, plans []familyPlan) error {
ids := make([]string, 0)
for _, plan := range plans {
for _, old := range plan.oldModels {
ids = append(ids, old.ID)
}
}
var active []struct {
LogicalModelID string
Status string
Count int64
}
if err := db.Model(&model.Task{}).
Select("logical_model_id, status, count(*) AS count").
Where("logical_model_id IN ? AND status IN ?", ids, []model.TaskStatus{model.TaskStatusQueued, model.TaskStatusRunning}).
Group("logical_model_id, status").
Scan(&active).Error; err != nil {
return err
}
if len(active) == 0 {
return nil
}
parts := make([]string, 0, len(active))
for _, item := range active {
parts = append(parts, fmt.Sprintf("%s/%s=%d", item.LogicalModelID, item.Status, item.Count))
}
return fmt.Errorf("存在 queued 或 running 旧 SKU 任务,拒绝归档:%s", strings.Join(parts, ", "))
}
func migrationAdmin(db *gorm.DB) (*model.User, error) {
var actor model.User
if err := db.Where("role = ?", model.UserRoleAdmin).Order("created_at asc").First(&actor).Error; err != nil {
return nil, fmt.Errorf("需要现有管理员作为迁移审计主体:%w", err)
}
return &actor, nil
}
func verifyPlans(db *gorm.DB, svc *service.Service, plans []familyPlan) error {
for _, plan := range plans {
var family model.LogicalModel
if err := db.Where("code = ? AND archived_at IS NULL", plan.definition.Code).First(&family).Error; err != nil {
return fmt.Errorf("读取模型家族 %s 失败:%w", plan.definition.Code, err)
}
for _, route := range plan.routes {
intent := routeIntent(route.spec)
resolved, err := svc.ResolveLogicalModel(family.ID, intent)
if err != nil {
return fmt.Errorf("验证模型家族 %s 的渠道 SKU %s 失败:%w", plan.definition.Code, route.channelModel.ModelKey, err)
}
if resolved.ChannelModel.ID != route.channelModel.ID {
return fmt.Errorf("模型家族 %s 的参数没有命中预期 SKU:期望 %s,实际 %s", plan.definition.Code, route.channelModel.ModelKey, resolved.ChannelModel.ModelKey)
}
}
}
return nil
}
func routeIntent(spec service.CapabilitySpec) service.ModelRequestIntent {
intent := service.ModelRequestIntent{Capability: spec.Capability, Inputs: map[string]int{}, Options: map[string]any{}}
if len(spec.Operations) > 0 {
intent.Operation = spec.Operations[0]
}
for name, constraint := range spec.Inputs {
intent.Inputs[name] = constraint.Min
}
for name, constraint := range spec.Options {
if len(constraint.Values) > 0 {
intent.Options[name] = constraint.Values[0]
}
}
return intent
}
func printPlans(plans []familyPlan) {
for _, plan := range plans {
oldCodes := make([]string, 0, len(plan.oldModels))
channelModels := make([]string, 0, len(plan.routes))
for _, old := range plan.oldModels {
oldCodes = append(oldCodes, old.Code)
}
for _, route := range plan.routes {
channelModels = append(channelModels, route.channelModel.ModelKey)
}
log.Printf("家族 %s (%s): 旧 SKU=[%s] 路由=[%s] 默认=%v 已存在=%t", plan.definition.Code, plan.definition.Name, strings.Join(oldCodes, ", "), strings.Join(channelModels, ", "), plan.request.DefaultOptions, plan.exists)
}
}

View File

@@ -0,0 +1,165 @@
package main
import (
"encoding/json"
"flag"
"fmt"
"log"
"os"
"sort"
"strings"
"infinite-canvas/backend/internal/database"
"infinite-canvas/backend/internal/model"
"infinite-canvas/backend/internal/repository"
"infinite-canvas/backend/internal/service"
"gorm.io/gorm"
)
// 此命令把“单条系统渠道线路驱动、但尚未绑定来源”的前台模型规范为系统渠道投影。
// 保留产品名称、排序、说明和旧 ID 映射;能力、默认参数、路由和价格统一从系统模型生成。
type sourcePlan struct {
logicalModel model.LogicalModel
source model.ChannelModel
}
func main() {
apply := flag.Bool("apply", false, "将符合条件的前台模型改为系统渠道模型驱动")
flag.Parse()
db, err := database.Open(database.Config{Driver: "postgres", DSN: os.Getenv("DATABASE_URL"), DataDir: os.Getenv("CANVAS_BACKEND_DATA_DIR")})
if err != nil {
log.Fatal(err)
}
if db.Dialector.Name() != "postgres" {
log.Fatal("前台模型目录刷新只允许连接 PostgreSQL")
}
if err := database.ConfigurePool(db); err != nil {
log.Fatal(err)
}
if err := database.MigrateSchema(db); err != nil {
log.Fatal(err)
}
repo := repository.New(db)
plans, err := buildPlans(repo)
if err != nil {
log.Fatal(err)
}
for _, plan := range plans {
log.Printf("%s: 当前前台模型=%s,系统模型=%s,协议=%s", plan.logicalModel.Code, plan.logicalModel.ID, plan.source.ModelKey, plan.source.Protocol)
}
if !*apply {
log.Print("dry-run 完成;确认后使用 --apply 写入。未匹配单条系统线路的前台模型不会被修改")
return
}
if err := ensureNoActiveTasks(db, plans); err != nil {
log.Fatal(err)
}
actor, err := migrationAdmin(db)
if err != nil {
log.Fatal(err)
}
svc := service.New(repo, os.Getenv("CANVAS_BACKEND_DATA_DIR"))
for _, plan := range plans {
if _, err := svc.SaveAdminLogicalModel(actor, plan.logicalModel.ID, service.LogicalModelRequest{
Code: plan.logicalModel.Code,
Name: plan.logicalModel.Name,
Icon: plan.logicalModel.Icon,
Description: plan.logicalModel.Description,
Capability: plan.source.Capability,
Enabled: plan.logicalModel.Enabled,
SortOrder: plan.logicalModel.SortOrder,
LegacyModelIDs: decodeLegacyModelIDs(plan.logicalModel.LegacyModelIDsJSON),
SourceChannelModelID: plan.source.ID,
}); err != nil {
log.Fatalf("刷新前台模型 %s 失败:%v", plan.logicalModel.Code, err)
}
log.Printf("已刷新前台模型:%s", plan.logicalModel.Code)
}
log.Print("前台模型目录刷新完成:能力、默认参数、路线和价格均以系统渠道模型为准")
}
func buildPlans(repo *repository.Repository) ([]sourcePlan, error) {
items, err := repo.LogicalModels(false)
if err != nil {
return nil, err
}
plans := make([]sourcePlan, 0)
for _, item := range items {
if strings.TrimSpace(item.SourceChannelModelID) != "" {
continue
}
graph, graphErr := repo.LogicalModelGraph(item.ID, false)
if graphErr != nil {
return nil, fmt.Errorf("读取前台模型 %s 的当前线路:%w", item.Code, graphErr)
}
if graph.Revision == nil || len(graph.Routes) != 1 || len(graph.ChannelModels) != 1 {
return nil, fmt.Errorf("前台模型 %s 不是单条有效系统线路,拒绝自动改写", item.Code)
}
route := graph.Routes[0]
source := graph.ChannelModels[0]
if route.ChannelModelID != source.ID {
return nil, fmt.Errorf("前台模型 %s 的当前线路与系统模型不一致", item.Code)
}
channel, channelErr := repo.AdminSystemChannel(source.ChannelID)
if channelErr != nil {
return nil, fmt.Errorf("前台模型 %s 的线路不是系统渠道:%w", item.Code, channelErr)
}
if !channel.Enabled || !source.Enabled || !source.PriceConfigured {
return nil, fmt.Errorf("前台模型 %s 的系统模型未启用或未配置价格", item.Code)
}
plans = append(plans, sourcePlan{logicalModel: item, source: source})
}
sort.Slice(plans, func(i, j int) bool {
return plans[i].logicalModel.Code < plans[j].logicalModel.Code
})
return plans, nil
}
func ensureNoActiveTasks(db *gorm.DB, plans []sourcePlan) error {
ids := make([]string, 0, len(plans))
for _, plan := range plans {
ids = append(ids, plan.logicalModel.ID)
}
if len(ids) == 0 {
return nil
}
var active []struct {
LogicalModelID string
Status string
Count int64
}
if err := db.Model(&model.Task{}).
Select("logical_model_id, status, count(*) AS count").
Where("logical_model_id IN ? AND status IN ?", ids, []model.TaskStatus{model.TaskStatusQueued, model.TaskStatusRunning}).
Group("logical_model_id, status").
Scan(&active).Error; err != nil {
return err
}
if len(active) == 0 {
return nil
}
parts := make([]string, 0, len(active))
for _, item := range active {
parts = append(parts, fmt.Sprintf("%s/%s=%d", item.LogicalModelID, item.Status, item.Count))
}
return fmt.Errorf("存在 queued 或 running 前台模型任务,拒绝刷新:%s", strings.Join(parts, ", "))
}
func migrationAdmin(db *gorm.DB) (*model.User, error) {
var actor model.User
if err := db.Where("role = ?", model.UserRoleAdmin).Order("created_at asc").First(&actor).Error; err != nil {
return nil, fmt.Errorf("需要现有管理员作为迁移审计主体:%w", err)
}
return &actor, nil
}
func decodeLegacyModelIDs(raw string) []string {
var ids []string
if err := json.Unmarshal([]byte(raw), &ids); err != nil {
return []string{}
}
return ids
}

View File

@@ -1,9 +1,12 @@
package database
import (
"crypto/sha256"
"database/sql"
"errors"
"fmt"
"strconv"
"strings"
"infinite-canvas/backend/internal/model"
@@ -20,6 +23,7 @@ func Models() []any {
&model.EmailVerificationCode{},
&model.ModelChannel{},
&model.ChannelModel{},
&model.ChannelModelPriceTier{},
&model.IDSequence{},
&model.LogicalModel{},
&model.LogicalModelRevision{},
@@ -90,6 +94,15 @@ func MigrateSchema(db *gorm.DB) error {
if err := db.AutoMigrate(Models()...); err != nil {
return err
}
if err := migrateChannelModelPriceTierSelectors(db); err != nil {
return err
}
if err := backfillChannelModelPriceTiers(db); err != nil {
return err
}
if err := migrateChannelModelPriceTierSelectors(db); err != nil {
return err
}
if err := dropLegacyPhysicalVariants(db); err != nil {
return err
}
@@ -107,9 +120,113 @@ func MigrateSchema(db *gorm.DB) error {
if err := db.Exec("DROP INDEX IF EXISTS idx_route_attempt_task_number").Error; err != nil {
return err
}
if err := db.Exec("DROP INDEX IF EXISTS idx_logical_model_source_active").Error; err != nil {
return err
}
if err := db.Exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_logical_model_source_active ON logical_models(source_channel_model_id) WHERE source_channel_model_id <> '' AND archived_at IS NULL").Error; err != nil {
return err
}
return db.Exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_users_email_nonempty ON users(lower(email)) WHERE email <> ''").Error
}
// migrateChannelModelPriceTierSelectors upgrades the old video-only unique key to
// a canonical SKU selector key. It never changes task, route-attempt, or billing
// foreign keys, so completed work remains auditable after a product SKU merge.
func migrateChannelModelPriceTierSelectors(db *gorm.DB) error {
if !db.Migrator().HasTable(&model.ChannelModelPriceTier{}) {
return nil
}
var tiers []model.ChannelModelPriceTier
if err := db.Unscoped().Find(&tiers).Error; err != nil {
return fmt.Errorf("读取规格价格档:%w", err)
}
for _, tier := range tiers {
selector := model.DecodeSKUSelector(tier.SelectorJSON)
if len(selector) == 0 {
selector = map[string]string{}
if resolution := strings.TrimSpace(tier.Resolution); resolution != "" && resolution != "*" {
selector["vquality"] = strings.ToLower(resolution)
}
if tier.VideoSeconds > 0 {
selector["videoSeconds"] = strconv.Itoa(tier.VideoSeconds)
}
}
_, key, err := model.CanonicalSKUSelector(selector)
if err != nil {
return fmt.Errorf("规范化规格价格档 %s:%w", tier.ID, err)
}
if tier.SelectorKey == key && tier.SelectorJSON == key {
continue
}
if err := db.Unscoped().Model(&model.ChannelModelPriceTier{}).Where("id = ?", tier.ID).Updates(map[string]any{"selector_key": key, "selector_json": key}).Error; err != nil {
return fmt.Errorf("更新规格价格档 %s:%w", tier.ID, err)
}
}
var duplicate struct {
ChannelModelID string
SelectorKey string
Count int64
}
err := db.Table("channel_model_price_tiers").
Select("channel_model_id, selector_key, COUNT(*) AS count").
Where("deleted_at IS NULL").
Group("channel_model_id, selector_key").
Having("COUNT(*) > 1").
First(&duplicate).Error
if err == nil {
return fmt.Errorf("渠道模型 %s 存在重复 SKU 选择器 %s,拒绝建立唯一索引", duplicate.ChannelModelID, duplicate.SelectorKey)
}
if !errors.Is(err, gorm.ErrRecordNotFound) {
return fmt.Errorf("检查重复 SKU 选择器:%w", err)
}
if err := db.Exec("DROP INDEX IF EXISTS idx_channel_model_price_tier_active").Error; err != nil {
return err
}
return db.Exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_channel_model_price_tier_active ON channel_model_price_tiers(channel_model_id, selector_key) WHERE deleted_at IS NULL").Error
}
// backfillChannelModelPriceTiers 将旧的单价模型无损映射为默认价格档。历史订单保存的是
// 金额快照,不能也不需要回写;这里仅保证升级后现有渠道模型仍能按原价格继续结算。
func backfillChannelModelPriceTiers(db *gorm.DB) error {
var channelModels []model.ChannelModel
if err := db.Find(&channelModels).Error; err != nil {
return fmt.Errorf("读取渠道模型价格档回填数据:%w", err)
}
for _, channelModel := range channelModels {
var count int64
if err := db.Model(&model.ChannelModelPriceTier{}).Where("channel_model_id = ?", channelModel.ID).Count(&count).Error; err != nil {
return fmt.Errorf("检查渠道模型 %s 价格档:%w", channelModel.ID, err)
}
if count > 0 {
continue
}
priceVersion := channelModel.PriceVersion
if priceVersion < 1 {
priceVersion = 1
}
digest := sha256.Sum256([]byte(channelModel.ID))
tier := model.ChannelModelPriceTier{
ID: fmt.Sprintf("PTIER-%x", digest[:15]),
ChannelModelID: channelModel.ID,
Resolution: "*",
VideoSeconds: 0,
ProviderModelKey: channelModel.ProviderModelKey,
BillingMode: channelModel.BillingMode,
UnitPriceMicrocredits: channelModel.UnitPriceMicrocredits,
InputTokenPriceMicrocredits: channelModel.InputTokenPriceMicrocredits,
OutputTokenPriceMicrocredits: channelModel.OutputTokenPriceMicrocredits,
CachedTokenPriceMicrocredits: channelModel.CachedTokenPriceMicrocredits,
PriceConfigured: channelModel.PriceConfigured,
Enabled: channelModel.Enabled,
PriceVersion: priceVersion,
}
if err := db.Create(&tier).Error; err != nil {
return fmt.Errorf("回填渠道模型 %s 默认价格档:%w", channelModel.ID, err)
}
}
return nil
}
// migrateLogicalRoutesToChannelModels 在模型结构切换前把历史 variant 外键转换为渠道模型外键。
// 写路径不能接受无法映射的历史引用,否则任务恢复和计费会失去明确执行目标。
func migrateLogicalRoutesToChannelModels(db *gorm.DB) error {

View File

@@ -39,6 +39,23 @@ func RegisterLogicalModelRoutes(r *gin.RouterGroup, svc *service.Service) {
}
ok(c, gin.H{"models": models})
})
r.POST("/models/:id/quote", func(c *gin.Context) {
if _, err := currentUser(c, svc); err != nil {
failService(c, err)
return
}
var intent service.ModelRequestIntent
if err := c.ShouldBindJSON(&intent); err != nil {
fail(c, http.StatusBadRequest, errors.New("模型报价请求格式错误"))
return
}
quote, err := svc.QuoteLogicalModel(c.Param("id"), intent)
if err != nil {
failService(c, err)
return
}
ok(c, gin.H{"quote": quote})
})
r.GET("/admin/logical-models", func(c *gin.Context) {
actor, err := currentUser(c, svc)
if err != nil {

View File

@@ -0,0 +1,34 @@
package model
import (
"encoding/json"
"strings"
)
// CanonicalSKUSelector stores selector values as strings so an API number and its
// textual equivalent cannot create two price rows for the same purchasable SKU.
func CanonicalSKUSelector(raw map[string]string) (map[string]string, string, error) {
selector := make(map[string]string, len(raw))
for rawKey, rawValue := range raw {
key := strings.TrimSpace(rawKey)
value := strings.TrimSpace(rawValue)
if key == "" || value == "" {
continue
}
selector[key] = value
}
encoded, err := json.Marshal(selector)
if err != nil {
return nil, "", err
}
return selector, string(encoded), nil
}
func DecodeSKUSelector(raw string) map[string]string {
selector := map[string]string{}
if strings.TrimSpace(raw) == "" {
return selector
}
_ = json.Unmarshal([]byte(raw), &selector)
return selector
}

View File

@@ -18,6 +18,8 @@ type ModelChannel struct {
APIFormat string `json:"apiFormat" gorm:"size:24"`
ConcurrencyLimit int `json:"concurrencyLimit"`
ModelsJSON string `json:"modelsJson" gorm:"type:text"`
// RetiredModelsJSON 记录已被一个模型家族吸收的上游 SKU,防止目录拉取时重新创建重复记录。
RetiredModelsJSON string `json:"-" gorm:"type:text"`
HeadersJSON string `json:"-" gorm:"type:text"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
@@ -28,6 +30,7 @@ type ChannelModel struct {
ID string `json:"id" gorm:"primaryKey;size:36"`
ChannelID string `json:"channelId" gorm:"size:36;index;uniqueIndex:idx_channel_model_key_active,priority:1,where:deleted_at IS NULL"`
ModelKey string `json:"modelKey" gorm:"size:120;uniqueIndex:idx_channel_model_key_active,priority:2,where:deleted_at IS NULL"`
ProviderModelKey string `json:"providerModelKey" gorm:"size:120"`
DisplayName string `json:"displayName" gorm:"size:160"`
Capability string `json:"capability" gorm:"size:32;index"`
Protocol ChannelInterfaceType `json:"protocol" gorm:"size:32;index"`
@@ -42,9 +45,39 @@ type ChannelModel struct {
CapabilityConfigJSON string `json:"-" gorm:"type:text"`
CapabilityVersion int64 `json:"capabilityVersion"`
CapabilityConfig map[string]any `json:"capabilityConfig,omitempty" gorm:"-"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
// PriceTiers 是系统渠道模型的价格真相。标量价格仅为旧调用与历史数据兼容,
// 新的创作端报价必须按所选规格命中一个价格档。
PriceTiers []ChannelModelPriceTier `json:"priceTiers" gorm:"-"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
}
// ChannelModelPriceTier 表示渠道模型在一个规格组合下的可执行上游 SKU 和用户价格。
// SelectorJSON 是规格选择器真相,例如 {"operation":"image_to_video","vquality":"720p",
// "videoSeconds":"10"} 或 {"operation":"text_to_image","quality":"2k"}。
// SelectorKey 是规范化 JSON,用于 PostgreSQL 的活动规格唯一约束。旧的 Resolution / VideoSeconds
// 仅保留给历史 API 与升级回填,不能再作为 SKU 唯一性依据。
type ChannelModelPriceTier struct {
ID string `json:"id" gorm:"primaryKey;size:36"`
ChannelModelID string `json:"channelModelId" gorm:"size:36;index;uniqueIndex:idx_channel_model_price_tier_active,priority:1,where:deleted_at IS NULL"`
SelectorKey string `json:"selectorKey" gorm:"size:500;not null;default:{};uniqueIndex:idx_channel_model_price_tier_active,priority:2,where:deleted_at IS NULL"`
SelectorJSON string `json:"-" gorm:"type:text;not null;default:{}"`
Selector map[string]string `json:"selector,omitempty" gorm:"-"`
Resolution string `json:"resolution" gorm:"size:24;not null;default:*"`
VideoSeconds int `json:"videoSeconds" gorm:"not null;default:0"`
ProviderModelKey string `json:"providerModelKey" gorm:"size:120"`
BillingMode string `json:"billingMode" gorm:"size:32"`
UnitPriceMicrocredits int64 `json:"unitPriceMicrocredits"`
InputTokenPriceMicrocredits int64 `json:"inputTokenPriceMicrocredits"`
OutputTokenPriceMicrocredits int64 `json:"outputTokenPriceMicrocredits"`
CachedTokenPriceMicrocredits int64 `json:"cachedTokenPriceMicrocredits"`
PriceConfigured bool `json:"priceConfigured" gorm:"index"`
Enabled bool `json:"enabled" gorm:"index"`
PriceVersion int64 `json:"priceVersion"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
}
type ApiCallLog struct {

View File

@@ -38,6 +38,10 @@ type BillingOrder struct {
TaskID string `json:"taskId,omitempty" gorm:"index;size:36"`
ChannelID string `json:"channelId" gorm:"index;size:36"`
ChannelModelID string `json:"channelModelId" gorm:"index;size:36"`
// PriceTierID/Version 记录任务实际命中的规格档;金额字段仍是不可变结算快照。
PriceTierID string `json:"priceTierId,omitempty" gorm:"index;size:36"`
PriceTierVersion int64 `json:"priceTierVersion"`
PriceSelectorJSON string `json:"-" gorm:"type:text"`
Model string `json:"model" gorm:"index;size:120"`
Capability string `json:"capability" gorm:"index;size:32"`
Scene string `json:"scene" gorm:"index;size:80"`

View File

@@ -20,14 +20,20 @@ type LogicalModel struct {
Enabled bool `json:"enabled" gorm:"index"`
SortOrder int `json:"sortOrder" gorm:"index"`
// RevisionSequence 记录已经分配的 revision 版本号,由数据库原子递增。
RevisionSequence int `json:"-" gorm:"not null;default:0"`
ActiveRevisionID string `json:"activeRevisionId" gorm:"size:36;index"`
RevisionSequence int `json:"-" gorm:"not null;default:0"`
ActiveRevisionID string `json:"activeRevisionId" gorm:"size:36;index"`
// SourceChannelModelID 标识这个前台目录项由哪个系统渠道模型投影而来。
// 管理员不再重复维护能力和价格,渠道模型保存时会创建新的前台 revision。
SourceChannelModelID string `json:"sourceChannelModelId,omitempty" gorm:"size:36;index"`
PricePolicy string `json:"pricePolicy" gorm:"size:24;default:unified"`
BillingMode string `json:"billingMode" gorm:"size:32"`
UnitPriceMicrocredits int64 `json:"unitPriceMicrocredits"`
InputPriceMicrocredits int64 `json:"inputPriceMicrocredits"`
OutputPriceMicrocredits int64 `json:"outputPriceMicrocredits"`
CachedPriceMicrocredits int64 `json:"cachedPriceMicrocredits"`
// LegacyModelIDsJSON 用于创作端把已保存的旧 SKU 选择无缝映射到新的模型家族。
// 这只是目录兼容信息,任务、路由尝试和账单仍固定引用其创建时的 ID 快照。
LegacyModelIDsJSON string `json:"-" gorm:"type:text"`
// ArchivedAt 仅从可选目录隐藏模型;历史任务、计费和审计仍需读取主体及不可变 revision。
ArchivedAt *time.Time `json:"-" gorm:"index"`
CreatedAt time.Time `json:"createdAt"`

View File

@@ -4,6 +4,7 @@ import (
"crypto/rand"
"encoding/hex"
"errors"
"strconv"
"strings"
"time"
@@ -73,7 +74,14 @@ func (r *Repository) ChannelModels(channelID string, includeDisabled bool) ([]mo
if !includeDisabled {
query = query.Where("enabled = ?", true)
}
return items, query.Find(&items).Error
if err := query.Find(&items).Error; err != nil {
return nil, err
}
pointers := make([]*model.ChannelModel, len(items))
for index := range items {
pointers[index] = &items[index]
}
return items, r.attachChannelModelPriceTiers(pointers)
}
func (r *Repository) ChannelModelByID(channelID string, id string) (*model.ChannelModel, error) {
@@ -81,6 +89,9 @@ func (r *Repository) ChannelModelByID(channelID string, id string) (*model.Chann
if err := r.db.First(&item, "id = ? AND channel_id = ?", id, channelID).Error; err != nil {
return nil, err
}
if err := r.attachChannelModelPriceTiers([]*model.ChannelModel{&item}); err != nil {
return nil, err
}
return &item, nil
}
@@ -89,6 +100,9 @@ func (r *Repository) ChannelModelByKey(channelID string, modelKey string) (*mode
if err := r.db.First(&item, "channel_id = ? AND model_key = ? AND enabled = ?", channelID, modelKey, true).Error; err != nil {
return nil, err
}
if err := r.attachChannelModelPriceTiers([]*model.ChannelModel{&item}); err != nil {
return nil, err
}
return &item, nil
}
@@ -97,6 +111,9 @@ func (r *Repository) ChannelModelByKeyIncludingDisabled(channelID string, modelK
if err := r.db.First(&item, "channel_id = ? AND model_key = ?", channelID, modelKey).Error; err != nil {
return nil, err
}
if err := r.attachChannelModelPriceTiers([]*model.ChannelModel{&item}); err != nil {
return nil, err
}
return &item, nil
}
@@ -104,6 +121,104 @@ func (r *Repository) SaveChannelModel(item *model.ChannelModel) error {
return r.db.Save(item).Error
}
// SaveChannelModelWithPriceTiers 原子保存系统模型与其活动价格档。移除价格档采用软删除,
// 让已结算订单的 PriceTierID 仍能回溯到原始配置版本。
func (r *Repository) SaveChannelModelWithPriceTiers(item *model.ChannelModel, tiers []model.ChannelModelPriceTier) error {
return r.db.Transaction(func(tx *gorm.DB) error {
var existing []model.ChannelModelPriceTier
if err := tx.Where("channel_model_id = ?", item.ID).Find(&existing).Error; err != nil {
return err
}
existingByKey := make(map[string]model.ChannelModelPriceTier, len(existing))
for _, tier := range existing {
existingByKey[channelModelPriceTierKey(tier)] = tier
}
selected := make(map[string]bool, len(tiers))
for index := range tiers {
tier := &tiers[index]
tier.ChannelModelID = item.ID
key := channelModelPriceTierKey(*tier)
if existingTier, exists := existingByKey[key]; exists {
tier.ID = existingTier.ID
tier.PriceVersion = existingTier.PriceVersion + 1
if err := tx.Save(tier).Error; err != nil {
return err
}
} else if err := tx.Create(tier).Error; err != nil {
return err
}
selected[tier.ID] = true
}
for _, tier := range existing {
if selected[tier.ID] {
continue
}
if err := tx.Delete(&tier).Error; err != nil {
return err
}
}
if err := tx.Save(item).Error; err != nil {
return err
}
return nil
})
}
func channelModelPriceTierKey(tier model.ChannelModelPriceTier) string {
if strings.TrimSpace(tier.SelectorKey) != "" {
return tier.SelectorKey
}
_, key, err := model.CanonicalSKUSelector(map[string]string{
"vquality": strings.TrimSpace(tier.Resolution),
"videoSeconds": strconv.Itoa(tier.VideoSeconds),
})
if err != nil {
return "{}"
}
return key
}
func (r *Repository) attachChannelModelPriceTiers(items []*model.ChannelModel) error {
if len(items) == 0 {
return nil
}
ids := make([]string, 0, len(items))
for _, item := range items {
ids = append(ids, item.ID)
}
var tiers []model.ChannelModelPriceTier
if err := r.db.Where("channel_model_id IN ?", ids).Order("selector_key asc, created_at asc").Find(&tiers).Error; err != nil {
return err
}
for index := range tiers {
tiers[index].Selector = model.DecodeSKUSelector(tiers[index].SelectorJSON)
}
tiersByModelID := make(map[string][]model.ChannelModelPriceTier, len(items))
for _, tier := range tiers {
tiersByModelID[tier.ChannelModelID] = append(tiersByModelID[tier.ChannelModelID], tier)
}
for _, item := range items {
item.PriceTiers = tiersByModelID[item.ID]
}
return nil
}
// PopulateChannelModelPriceTiers 将价格档附着到已经查询出的渠道模型,供路由关系图批量加载使用。
func (r *Repository) PopulateChannelModelPriceTiers(items []model.ChannelModel) error {
pointers := make([]*model.ChannelModel, len(items))
for index := range items {
pointers[index] = &items[index]
}
return r.attachChannelModelPriceTiers(pointers)
}
func (r *Repository) PopulateChannelModelPriceTier(item *model.ChannelModel) error {
if item == nil {
return nil
}
return r.attachChannelModelPriceTiers([]*model.ChannelModel{item})
}
func (r *Repository) DeleteChannelModel(channelID string, id string, modelsJSON string, now time.Time) error {
return r.db.Transaction(func(tx *gorm.DB) error {
var activeReferences int64

View File

@@ -37,6 +37,22 @@ func (r *Repository) LogicalModel(id string) (*model.LogicalModel, error) {
return &item, nil
}
func (r *Repository) LogicalModelsBySourceChannelModelID(channelModelID string) ([]model.LogicalModel, error) {
var items []model.LogicalModel
return items, r.db.Where("source_channel_model_id = ? AND archived_at IS NULL", channelModelID).Order("sort_order asc, created_at asc").Find(&items).Error
}
func (r *Repository) LogicalModelsByOnlyActiveChannelModelID(channelModelID string) ([]model.LogicalModel, error) {
var items []model.LogicalModel
query := r.db.Model(&model.LogicalModel{}).
Joins("JOIN logical_model_routes AS route ON route.logical_model_revision_id = logical_models.active_revision_id").
Where("logical_models.archived_at IS NULL").
Group("logical_models.id").
Having("COUNT(route.id) = 1 AND MAX(route.channel_model_id) = ?", channelModelID).
Order("logical_models.sort_order asc, logical_models.created_at asc")
return items, query.Find(&items).Error
}
func (r *Repository) LogicalModelRevision(id string) (*model.LogicalModelRevision, error) {
var item model.LogicalModelRevision
if err := r.db.First(&item, "id = ?", id).Error; err != nil {
@@ -72,7 +88,10 @@ func (r *Repository) ChannelModelsByIDs(ids []string) ([]model.ChannelModel, err
if len(ids) == 0 {
return items, nil
}
return items, r.db.Where("id IN ?", ids).Find(&items).Error
if err := r.db.Where("id IN ?", ids).Find(&items).Error; err != nil {
return nil, err
}
return items, r.PopulateChannelModelPriceTiers(items)
}
func (r *Repository) SystemChannelsByIDs(ids []string, includeDisabled bool) ([]model.ModelChannel, error) {
@@ -92,6 +111,9 @@ func (r *Repository) ChannelModel(id string) (*model.ChannelModel, error) {
if err := r.db.First(&item, "id = ?", id).Error; err != nil {
return nil, err
}
if err := r.PopulateChannelModelPriceTier(&item); err != nil {
return nil, err
}
return &item, nil
}
@@ -316,6 +338,8 @@ func (r *Repository) SwitchTaskLogicalRoute(taskID string, expectedRouteID strin
}
updates["billing_mode"] = replacement.BillingMode
updates["price_version"] = replacement.PriceVersion
updates["price_tier_id"] = replacement.PriceTierID
updates["price_tier_version"] = replacement.PriceTierVersion
updates["unit_price_microcredits"] = replacement.UnitPriceMicrocredits
updates["multiplier_basis_points"] = replacement.MultiplierBasisPoints
updates["quantity"] = replacement.Quantity
@@ -353,12 +377,14 @@ func (r *Repository) SaveLogicalModelBundle(item *model.LogicalModel, revision *
"capability": item.Capability,
"enabled": item.Enabled,
"sort_order": item.SortOrder,
"source_channel_model_id": item.SourceChannelModelID,
"price_policy": item.PricePolicy,
"billing_mode": item.BillingMode,
"unit_price_microcredits": item.UnitPriceMicrocredits,
"input_price_microcredits": item.InputPriceMicrocredits,
"output_price_microcredits": item.OutputPriceMicrocredits,
"cached_price_microcredits": item.CachedPriceMicrocredits,
"legacy_model_ids_json": item.LegacyModelIDsJSON,
"updated_at": item.UpdatedAt,
})
if result.Error != nil {

View File

@@ -835,7 +835,12 @@ func publicChannel(channel model.ModelChannel, admin bool, channelModels []model
}
models = append(models, item.ModelKey)
if item.Enabled && item.PriceConfigured {
capabilityConfig, _ := DecodeModelCapabilityConfig(item.CapabilityConfigJSON)
capabilityConfig, decodeErr := DecodeModelCapabilityConfig(item.CapabilityConfigJSON)
if decodeErr == nil && capabilityConfig != nil {
if normalized, normalizeErr := NormalizeModelCapabilityConfig(item.Capability, string(item.Protocol), firstNonEmpty(item.ProviderModelKey, item.ModelKey), channel.APIFormat, capabilityConfig); normalizeErr == nil {
capabilityConfig = normalized
}
}
modelCosts = append(modelCosts, PublicChannelModelPrice{Model: item.ModelKey, DisplayName: item.DisplayName, Capability: item.Capability, Protocol: item.Protocol, BillingMode: item.BillingMode, UnitPriceMicrocredits: item.UnitPriceMicrocredits, InputTokenPriceMicrocredits: item.InputTokenPriceMicrocredits, OutputTokenPriceMicrocredits: item.OutputTokenPriceMicrocredits, CachedTokenPriceMicrocredits: item.CachedTokenPriceMicrocredits, CapabilityConfig: capabilityConfig})
}
}

View File

@@ -2,6 +2,7 @@ package service
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
@@ -174,6 +175,69 @@ func TestSaveAdminChannelModelRejectsActiveDuplicateKey(t *testing.T) {
}
}
func TestResolveProviderConfigMapsSKUToProviderModel(t *testing.T) {
svc, db := newChannelModelTestService(t)
svc.dataDir = t.TempDir()
channel := model.ModelChannel{
ID: "channel-1", Scope: model.ChannelScopeSystem, Enabled: true, Name: "Seedance",
BaseURL: "https://ark.cn-beijing.volces.com/api/v3", APIKey: "test-key", APIFormat: "openai", ModelsJSON: `["seedance-2-5-480p"]`,
}
if err := svc.encryptSystemChannelSecrets(&channel); err != nil {
t.Fatal(err)
}
item := model.ChannelModel{
ID: "model-1", ChannelID: channel.ID, ModelKey: "seedance-2-5-480p", ProviderModelKey: "doubao-seedance-2-5",
Capability: "video", Protocol: model.ChannelInterfaceVolcengineArkVideo, Enabled: true,
}
if err := db.Create(&channel).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&item).Error; err != nil {
t.Fatal(err)
}
config, err := svc.resolveProviderConfig(providerConfig{ChannelID: channel.ID, Model: item.ModelKey})
if err != nil {
t.Fatal(err)
}
if config.ChannelModelKey != item.ModelKey || config.Model != item.ProviderModelKey {
t.Fatalf("resolved config = %#v", config)
}
}
func TestValidateTaskCapabilityFixesSingleResolutionSKU(t *testing.T) {
svc, db := newChannelModelTestService(t)
video := DefaultModelCapabilityConfigForModel(string(model.ChannelInterfaceVolcengineArkVideo), "doubao-seedance-2-5").Video
video.References.MaxVideos = 0
video.Resolutions = []string{"480p"}
video.DefaultResolution = "480p"
encoded, err := json.Marshal(&ModelCapabilityConfig{Version: 1, Video: video})
if err != nil {
t.Fatal(err)
}
channelModel := model.ChannelModel{
ID: "model-480p", ChannelID: "channel-1", ModelKey: "doubao-seedance-2-5-480p", ProviderModelKey: "doubao-seedance-2-5",
Capability: "video", Protocol: model.ChannelInterfaceVolcengineArkVideo, Enabled: true, CapabilityConfigJSON: string(encoded),
}
if err := db.Create(&channelModel).Error; err != nil {
t.Fatal(err)
}
input := map[string]any{
"mode": "video",
"config": map[string]any{
"channelId": "channel-1", "model": channelModel.ModelKey, "vquality": "auto", "videoSeconds": "6", "size": "16:9",
"videoGenerateAudio": "true", "videoWatermark": "false",
},
}
if err := svc.ValidateTaskCapability(input); err != nil {
t.Fatal(err)
}
config := input["config"].(map[string]any)
if got := config["vquality"]; got != "480p" {
t.Fatalf("vquality = %#v, want 480p", got)
}
}
func newChannelModelTestService(t *testing.T) (*Service, *gorm.DB) {
t.Helper()
db, err := gorm.Open(sqlite.Open("file:"+newID()+"?mode=memory&cache=shared"), &gorm.Config{})

View File

@@ -4,7 +4,9 @@ import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"strconv"
"strings"
"time"
@@ -15,18 +17,38 @@ import (
)
type ChannelModelRequest struct {
ModelKey string `json:"modelKey"`
DisplayName string `json:"displayName"`
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"`
ModelKey string `json:"modelKey"`
ProviderModelKey string `json:"providerModelKey"`
DisplayName string `json:"displayName"`
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"`
}
// ChannelModelPriceTierRequest 是系统渠道内某个规格的上游 SKU 与结算价格。
// Resolution="*"、VideoSeconds=0 分别表示任意分辨率和任意时长。
type ChannelModelPriceTierRequest struct {
// Selector 是 SKU 的规范匹配条件。支持 operation、quality、size、vquality、videoSeconds;
// 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 为本次新建条数。
@@ -73,9 +95,18 @@ func (s *Service) AdminChannelModels(actor *model.User, channelID string) ([]mod
if strings.TrimSpace(items[index].CapabilityConfigJSON) == "" {
continue
}
var config map[string]any
if json.Unmarshal([]byte(items[index].CapabilityConfigJSON), &config) == nil {
items[index].CapabilityConfig = config
config, decodeErr := DecodeModelCapabilityConfig(items[index].CapabilityConfigJSON)
if decodeErr != nil || config == nil {
continue
}
normalized, normalizeErr := NormalizeModelCapabilityConfig(items[index].Capability, string(items[index].Protocol), firstNonEmpty(items[index].ProviderModelKey, items[index].ModelKey), channel.APIFormat, 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
@@ -117,18 +148,21 @@ func (s *Service) FetchAdminChannelModels(ctx context.Context, actor *model.User
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[item.ModelKey] = struct{}{}
known[channelModelCatalogKey(item.ModelKey)] = struct{}{}
}
retired := retiredChannelModelKeys(channel.RetiredModelsJSON)
missing := make([]model.ChannelModel, 0, len(models))
for _, name := range models {
if _, ok := known[name]; ok {
name = strings.TrimPrefix(strings.TrimSpace(name), "models/")
key := channelModelCatalogKey(name)
if _, ok := known[key]; ok || retired[key] {
continue
}
// 自动发现不能绕过定价边界;新模型由管理员定价后再手动启用。
@@ -156,7 +190,7 @@ func (s *Service) SaveAdminChannelModel(actor *model.User, channelID string, id
if err != nil {
return nil, err
}
modelKey, capability, protocol, err := normalizeChannelModelContract(channel, req)
modelKey, providerModelKey, capability, protocol, err := normalizeChannelModelContract(channel, req)
if err != nil {
return nil, err
}
@@ -169,35 +203,13 @@ func (s *Service) SaveAdminChannelModel(actor *model.User, channelID string, id
return nil, BadAuthRequest("该渠道已存在模型 " + modelKey + ",请直接编辑已有模型")
}
if capability == "text" || capability == "image" || capability == "video" {
if _, err := NormalizeModelCapabilityConfig(capability, string(protocol), req.CapabilityConfig); err != nil {
if _, err := NormalizeModelCapabilityConfig(capability, string(protocol), providerModelKey, channel.APIFormat, req.CapabilityConfig); err != nil {
return nil, err
}
}
billingMode := strings.TrimSpace(req.BillingMode)
if billingMode == "" {
billingMode = "fixed_request"
}
if billingMode != "fixed_request" && billingMode != "per_second" && billingMode != "token" {
return nil, BadAuthRequest("模型计费方式仅支持按次、按秒或 Token")
}
if billingMode == "per_second" && capability != "video" {
return nil, BadAuthRequest("只有视频模型可以按秒计费")
}
if billingMode == "token" && !supportsTokenBilling(capability, protocol) {
return nil, BadAuthRequest("Token 计费仅支持文本模型和火山方舟视频协议")
}
if req.UnitPriceMicrocredits < 0 || req.InputTokenPriceMicrocredits < 0 || req.OutputTokenPriceMicrocredits < 0 || req.CachedTokenPriceMicrocredits < 0 {
return nil, BadAuthRequest("模型积分价格不能小于 0")
}
if billingMode == "token" && req.InputTokenPriceMicrocredits == 0 && req.OutputTokenPriceMicrocredits == 0 && req.CachedTokenPriceMicrocredits == 0 {
return nil, BadAuthRequest("Token 计费至少需要配置一项价格")
}
if billingMode == "token" && capability == "video" && req.OutputTokenPriceMicrocredits == 0 {
return nil, BadAuthRequest("火山方舟视频 Token 计费需要配置每百万视频 Token 价格")
}
const maxTokenPriceMicrocredits = int64(1_000_000) * CreditScale
if req.InputTokenPriceMicrocredits > maxTokenPriceMicrocredits || req.OutputTokenPriceMicrocredits > maxTokenPriceMicrocredits || req.CachedTokenPriceMicrocredits > maxTokenPriceMicrocredits {
return nil, BadAuthRequest("Token 每百万用量价格不能超过 1,000,000 积分")
tiers, err := s.normalizeChannelModelPriceTiers(req, capability, protocol, providerModelKey)
if err != nil {
return nil, err
}
modelID, err := s.repo.NextPrefixedID("MODEL")
if err != nil {
@@ -212,20 +224,16 @@ func (s *Service) SaveAdminChannelModel(actor *model.User, channelID string, id
item.PriceVersion++
}
item.ModelKey = modelKey
item.ProviderModelKey = providerModelKey
item.DisplayName = strings.TrimSpace(req.DisplayName)
if item.DisplayName == "" {
item.DisplayName = modelKey
}
item.Capability = capability
item.Protocol = protocol
item.BillingMode = billingMode
item.UnitPriceMicrocredits = req.UnitPriceMicrocredits
item.InputTokenPriceMicrocredits = req.InputTokenPriceMicrocredits
item.OutputTokenPriceMicrocredits = req.OutputTokenPriceMicrocredits
item.CachedTokenPriceMicrocredits = req.CachedTokenPriceMicrocredits
item.PriceConfigured = req.PriceConfigured
s.applyChannelModelPriceTierSummary(item, tiers)
if capability == "text" || capability == "image" || capability == "video" {
capabilityConfig, normalizeErr := NormalizeModelCapabilityConfig(capability, string(protocol), req.CapabilityConfig)
capabilityConfig, normalizeErr := NormalizeModelCapabilityConfig(capability, string(protocol), providerModelKey, channel.APIFormat, req.CapabilityConfig)
if normalizeErr != nil {
return nil, normalizeErr
}
@@ -244,21 +252,263 @@ func (s *Service) SaveAdminChannelModel(actor *model.User, channelID string, id
if req.Enabled != nil {
item.Enabled = *req.Enabled
}
// 供应线路直接引用渠道模型,修改能力参数后会从这一唯一事实来源实时生成线路能力。
if err := s.repo.SaveChannelModel(item); err != nil {
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 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, protocol model.ChannelInterfaceType) bool {
return capability == "text" || (capability == "video" && protocol == model.ChannelInterfaceVolcengineArkVideo)
}
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{
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)
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("只有视频模型可以按时长配置价格档")
}
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 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 !input.PriceConfigured {
return nil
}
if billingMode == "token" && input.InputTokenPriceMicrocredits == 0 && input.OutputTokenPriceMicrocredits == 0 && input.CachedTokenPriceMicrocredits == 0 {
return BadAuthRequest("Token 计费至少需要配置一项价格")
}
if billingMode == "token" && capability == "video" && input.OutputTokenPriceMicrocredits == 0 {
return BadAuthRequest("火山方舟视频 Token 计费需要配置每百万视频 Token 价格")
}
const maxTokenPriceMicrocredits = int64(1_000_000) * CreditScale
if input.InputTokenPriceMicrocredits > maxTokenPriceMicrocredits || input.OutputTokenPriceMicrocredits > maxTokenPriceMicrocredits || input.CachedTokenPriceMicrocredits > maxTokenPriceMicrocredits {
return BadAuthRequest("Token 每百万用量价格不能超过 1,000,000 积分")
}
return nil
}
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
@@ -267,12 +517,12 @@ func (s *Service) TestAdminChannelModel(ctx context.Context, actor *model.User,
if err != nil {
return nil, err
}
modelKey, capability, protocol, err := normalizeChannelModelContract(channel, req)
modelKey, providerModelKey, capability, protocol, err := normalizeChannelModelContract(channel, req)
if err != nil {
return nil, err
}
if capability == "text" || capability == "image" || capability == "video" {
if _, err := NormalizeModelCapabilityConfig(capability, string(protocol), req.CapabilityConfig); err != nil {
if _, err := NormalizeModelCapabilityConfig(capability, string(protocol), providerModelKey, channel.APIFormat, req.CapabilityConfig); err != nil {
return nil, err
}
}
@@ -302,7 +552,7 @@ func (s *Service) TestAdminChannelModel(ctx context.Context, actor *model.User,
imageSize, imageQuality := "", ""
var imageProfile *ImageCapabilityConfig
if capability == "image" {
profile, normalizeErr := NormalizeModelCapabilityConfig(capability, string(protocol), req.CapabilityConfig)
profile, normalizeErr := NormalizeModelCapabilityConfig(capability, string(protocol), providerModelKey, channel.APIFormat, req.CapabilityConfig)
if normalizeErr != nil {
return nil, normalizeErr
}
@@ -321,7 +571,8 @@ func (s *Service) TestAdminChannelModel(ctx context.Context, actor *model.User,
APIKey: channel.APIKey,
SecretKey: channel.SecretKey,
Headers: headers,
Model: modelKey,
Model: providerModelKey,
ChannelModelKey: modelKey,
Size: map[string]string{"image": imageSize, "video": "16:9"}[capability],
Quality: imageQuality,
Count: "1",
@@ -384,26 +635,30 @@ func imageTestDefaults(profile *ImageCapabilityConfig) (string, string) {
return size, quality
}
func normalizeChannelModelContract(channel *model.ModelChannel, req ChannelModelRequest) (string, string, model.ChannelInterfaceType, error) {
func normalizeChannelModelContract(channel *model.ModelChannel, req ChannelModelRequest) (string, string, string, model.ChannelInterfaceType, error) {
modelKey := strings.TrimPrefix(strings.TrimSpace(req.ModelKey), "models/")
if modelKey == "" {
return "", "", "", BadAuthRequest("请填写模型标识")
return "", "", "", "", BadAuthRequest("请填写模型标识")
}
providerModelKey := strings.TrimPrefix(strings.TrimSpace(req.ProviderModelKey), "models/")
if providerModelKey == "" {
providerModelKey = modelKey
}
capability := normalizeCapability(req.Capability)
if capability == "" {
return "", "", "", BadAuthRequest("请选择模型能力")
return "", "", "", "", BadAuthRequest("请选择模型能力")
}
protocol := model.ChannelInterfaceType(strings.TrimSpace(req.Protocol))
if !validChannelInterfaceType(protocol) {
return "", "", "", BadAuthRequest("请选择有效的模型请求协议")
return "", "", "", "", BadAuthRequest("请选择有效的模型请求协议")
}
if expected := capabilityForProtocol(protocol); expected != "" && expected != capability {
return "", "", "", BadAuthRequest("模型能力与请求协议不匹配")
return "", "", "", "", BadAuthRequest("模型能力与请求协议不匹配")
}
if (protocol == model.ChannelInterfaceVolcengineJiMengImage || protocol == model.ChannelInterfaceVolcengineJiMengVideo) && (strings.TrimSpace(channel.APIKey) == "" || strings.TrimSpace(channel.SecretKey) == "") {
return "", "", "", BadAuthRequest("即梦官方协议需要先在渠道中配置 Access Key 和 Secret Key")
return "", "", "", "", BadAuthRequest("即梦官方协议需要先在渠道中配置 Access Key 和 Secret Key")
}
return modelKey, capability, protocol, nil
return modelKey, providerModelKey, capability, protocol, nil
}
func (s *Service) DeleteAdminChannelModel(actor *model.User, channelID string, id string) error {
@@ -460,8 +715,12 @@ func (s *Service) syncInitialChannelModels(channel *model.ModelChannel, names []
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 {
if !item.Enabled {
@@ -494,6 +753,22 @@ func (s *Service) syncInitialChannelModels(channel *model.ModelChannel, names []
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 {

View File

@@ -8,20 +8,33 @@ import (
func TestNormalizeChannelModelContract(t *testing.T) {
channel := &model.ModelChannel{APIKey: "test-key"}
modelKey, capability, protocol, err := normalizeChannelModelContract(channel, ChannelModelRequest{
modelKey, providerModelKey, capability, protocol, err := normalizeChannelModelContract(channel, ChannelModelRequest{
ModelKey: "models/gpt-test", Capability: "text", Protocol: string(model.ChannelInterfaceChatCompletion),
})
if err != nil {
t.Fatalf("normalizeChannelModelContract() error = %v", err)
}
if modelKey != "gpt-test" || capability != "text" || protocol != model.ChannelInterfaceChatCompletion {
t.Fatalf("contract = %q, %q, %q", modelKey, capability, protocol)
if modelKey != "gpt-test" || providerModelKey != "gpt-test" || capability != "text" || protocol != model.ChannelInterfaceChatCompletion {
t.Fatalf("contract = %q, %q, %q, %q", modelKey, providerModelKey, capability, protocol)
}
}
func TestNormalizeChannelModelContractPreservesProviderModelKey(t *testing.T) {
channel := &model.ModelChannel{APIKey: "test-key"}
modelKey, providerModelKey, _, _, err := normalizeChannelModelContract(channel, ChannelModelRequest{
ModelKey: "seedance-2-5-480p", ProviderModelKey: "models/doubao-seedance-2-5", Capability: "video", Protocol: string(model.ChannelInterfaceVolcengineArkVideo),
})
if err != nil {
t.Fatalf("normalizeChannelModelContract() error = %v", err)
}
if modelKey != "seedance-2-5-480p" || providerModelKey != "doubao-seedance-2-5" {
t.Fatalf("contract = %q, %q", modelKey, providerModelKey)
}
}
func TestNormalizeChannelModelContractRejectsCapabilityMismatch(t *testing.T) {
channel := &model.ModelChannel{APIKey: "test-key"}
_, _, _, err := normalizeChannelModelContract(channel, ChannelModelRequest{
_, _, _, _, err := normalizeChannelModelContract(channel, ChannelModelRequest{
ModelKey: "image-test", Capability: "text", Protocol: string(model.ChannelInterfaceOpenAIImage),
})
if err == nil {
@@ -31,7 +44,7 @@ func TestNormalizeChannelModelContractRejectsCapabilityMismatch(t *testing.T) {
func TestNormalizeChannelModelContractRequiresJiMengSecret(t *testing.T) {
channel := &model.ModelChannel{APIKey: "access-key"}
_, _, _, err := normalizeChannelModelContract(channel, ChannelModelRequest{
_, _, _, _, err := normalizeChannelModelContract(channel, ChannelModelRequest{
ModelKey: "jimeng-test", Capability: "image", Protocol: string(model.ChannelInterfaceVolcengineJiMengImage),
})
if err == nil {

View File

@@ -474,7 +474,7 @@ func (s *Service) newLogicalModelBillingOrder(userID string, task *model.Task, i
capability = capabilityFromTaskType(task.Type)
}
if logicalModel.PricePolicy == "channel" {
order, priceErr := s.newBillingOrder(userID, task.ID, "task:"+task.ID+":"+newID(), channelModel.ChannelID, channelModel.ModelKey, capability, firstNonEmpty(strings.TrimSpace(task.Operation), task.Type), billingQuantity(capability, config["videoSeconds"]), estimateTaskBillingTokens(input, capability))
order, priceErr := s.newBillingOrderWithPriceTier(userID, task.ID, "task:"+task.ID+":"+newID(), channelModel.ChannelID, channelModel.ModelKey, capability, firstNonEmpty(strings.TrimSpace(task.Operation), task.Type), billingQuantity(capability, config["videoSeconds"]), estimateTaskBillingTokens(input, capability), strings.TrimSpace(fmt.Sprint(config["priceTierId"])))
if priceErr != nil {
return nil, priceErr
}
@@ -557,6 +557,10 @@ func (s *Service) ReserveProxyBillingWithBody(userID string, channelID string, m
}
func (s *Service) newBillingOrder(userID string, taskID string, idempotencyKey string, channelID string, modelKey string, capability string, scene string, requestedQuantity int64, tokenEstimate tokenBillingEstimate) (*model.BillingOrder, error) {
return s.newBillingOrderWithPriceTier(userID, taskID, idempotencyKey, channelID, modelKey, capability, scene, requestedQuantity, tokenEstimate, "")
}
func (s *Service) newBillingOrderWithPriceTier(userID string, taskID string, idempotencyKey string, channelID string, modelKey string, capability string, scene string, requestedQuantity int64, tokenEstimate tokenBillingEstimate, priceTierID string) (*model.BillingOrder, error) {
item, err := s.repo.ChannelModelByKey(channelID, modelKey)
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, BadAuthRequest("当前模型暂时不可用,请重新选择")
@@ -564,12 +568,13 @@ func (s *Service) newBillingOrder(userID string, taskID string, idempotencyKey s
if err != nil {
return nil, err
}
if !item.PriceConfigured {
return nil, BadAuthRequest("当前模型尚未配置用户积分价格")
tier := channelModelPriceTierForBilling(*item, priceTierID, capability)
if tier == nil {
return nil, BadAuthRequest("当前模型尚未配置所选规格的用户积分价格")
}
quantity := int64(1)
amount := int64(0)
switch item.BillingMode {
switch tier.BillingMode {
case "fixed_request":
case "per_second":
if item.Capability != "video" || capability != "video" {
@@ -601,25 +606,38 @@ func (s *Service) newBillingOrder(userID string, taskID string, idempotencyKey s
if configured := policy.ModelMultiplierBPS[modelKey]; configured > 0 {
multiplierBPS = configured
}
if item.BillingMode == "token" {
amount, err = tokenEstimateAmount(item, tokenEstimate, multiplierBPS)
if tier.BillingMode == "token" {
amount, err = tokenEstimateAmount(&model.ChannelModel{InputTokenPriceMicrocredits: tier.InputTokenPriceMicrocredits, OutputTokenPriceMicrocredits: tier.OutputTokenPriceMicrocredits, CachedTokenPriceMicrocredits: tier.CachedTokenPriceMicrocredits}, tokenEstimate, multiplierBPS)
} else {
amount, err = creditAmount(item.UnitPriceMicrocredits, quantity, multiplierBPS)
amount, err = creditAmount(tier.UnitPriceMicrocredits, quantity, multiplierBPS)
}
if err != nil {
return nil, err
}
return &model.BillingOrder{
ID: newID(), UserID: userID, IdempotencyKey: idempotencyKey, TaskID: taskID,
ChannelID: channelID, ChannelModelID: item.ID, Model: modelKey, Capability: capability,
Scene: truncateRunes(scene, 80), BillingMode: item.BillingMode, PriceVersion: item.PriceVersion,
UnitPriceMicrocredits: item.UnitPriceMicrocredits, MultiplierBasisPoints: multiplierBPS, Quantity: quantity, AmountMicrocredits: amount,
ReservedAmountMicrocredits: amount, InputTokenPriceMicrocredits: item.InputTokenPriceMicrocredits,
OutputTokenPriceMicrocredits: item.OutputTokenPriceMicrocredits, CachedTokenPriceMicrocredits: item.CachedTokenPriceMicrocredits,
ChannelID: channelID, ChannelModelID: item.ID, PriceTierID: tier.ID, PriceTierVersion: tier.PriceVersion, PriceSelectorJSON: tier.SelectorJSON, Model: modelKey, Capability: capability,
Scene: truncateRunes(scene, 80), BillingMode: tier.BillingMode, PriceVersion: item.PriceVersion,
UnitPriceMicrocredits: tier.UnitPriceMicrocredits, MultiplierBasisPoints: multiplierBPS, Quantity: quantity, AmountMicrocredits: amount,
ReservedAmountMicrocredits: amount, InputTokenPriceMicrocredits: tier.InputTokenPriceMicrocredits,
OutputTokenPriceMicrocredits: tier.OutputTokenPriceMicrocredits, CachedTokenPriceMicrocredits: tier.CachedTokenPriceMicrocredits,
Status: model.BillingStatusReserved,
}, nil
}
func channelModelPriceTierForBilling(channelModel model.ChannelModel, priceTierID string, capability string) *model.ChannelModelPriceTier {
if priceTierID != "" {
for index := range channelModel.PriceTiers {
tier := &channelModel.PriceTiers[index]
if tier.ID == priceTierID && tier.Enabled && tier.PriceConfigured {
return tier
}
}
return nil
}
return channelModelPriceTierForIntent(channelModel, ModelRequestIntent{Capability: capability, Options: map[string]any{}})
}
func estimateTaskTokens(input map[string]any) tokenBillingEstimate {
encoded, _ := json.Marshal(input)
return tokenBillingEstimate{InputTokens: estimatedTokens(encoded), OutputTokens: maxOutputTokens(input)}

View File

@@ -0,0 +1,113 @@
package service
import "infinite-canvas/backend/internal/model"
// LogicalModelQuote 是创作端当前参数命中的实际供应线路报价。
// Token 视频在上游返回真实 usage 前只能预估,创建任务仍以账务预留逻辑为准。
type LogicalModelQuote struct {
LogicalModelID string `json:"logicalModelId"`
BillingMode string `json:"billingMode"`
Quantity int64 `json:"quantity"`
AmountMicrocredits int64 `json:"amountMicrocredits"`
Estimated bool `json:"estimated"`
}
func (s *Service) QuoteLogicalModel(logicalModelID string, intent ModelRequestIntent) (*LogicalModelQuote, error) {
routed, err := s.ResolveLogicalModel(logicalModelID, intent)
if err != nil {
return nil, err
}
capability := normalizeCapability(intent.Capability)
if capability == "" {
return nil, BadAuthRequest("报价请求缺少模型能力类型")
}
resolvedIntent := intent
resolvedIntent.Options = mergeIntentDefaults(intent.Options, routed.Defaults)
input := quoteInput(resolvedIntent, routed.ChannelModel.ModelKey)
quantity := billingQuantity(capability, inputConfigValue(input, "videoSeconds"))
if capability != "video" {
quantity = 1
}
tokenEstimate := estimateTaskBillingTokens(input, capability)
if routed.LogicalModel.PricePolicy == "channel" {
priceTierID := ""
if routed.PriceTier != nil {
priceTierID = routed.PriceTier.ID
}
order, billingErr := s.newBillingOrderWithPriceTier("", "", "quote", routed.ChannelModel.ChannelID, routed.ChannelModel.ModelKey, capability, "model_quote", quantity, tokenEstimate, priceTierID)
if billingErr != nil {
return nil, billingErr
}
return &LogicalModelQuote{
LogicalModelID: routed.LogicalModel.ID,
BillingMode: order.BillingMode,
Quantity: order.Quantity,
AmountMicrocredits: order.AmountMicrocredits,
Estimated: order.BillingMode == "token",
}, nil
}
if routed.LogicalModel.PricePolicy != "unified" {
return nil, BadAuthRequest("当前模型价格策略无效")
}
amount := int64(0)
switch routed.LogicalModel.BillingMode {
case "fixed_request":
quantity = 1
amount = routed.LogicalModel.UnitPriceMicrocredits
case "per_second":
if capability != "video" || quantity <= 0 {
return nil, BadAuthRequest("当前模型按时长计费,但请求未提供有效时长")
}
amount, err = creditAmount(routed.LogicalModel.UnitPriceMicrocredits, quantity, 10_000)
case "token":
if routed.ChannelModel.Capability != capability || !supportsTokenBilling(capability, routed.ChannelModel.Protocol) {
return nil, BadAuthRequest("当前供应线路不支持前台模型的 Token 计费方式")
}
pricing := &model.ChannelModel{
InputTokenPriceMicrocredits: routed.LogicalModel.InputPriceMicrocredits,
OutputTokenPriceMicrocredits: routed.LogicalModel.OutputPriceMicrocredits,
CachedTokenPriceMicrocredits: routed.LogicalModel.CachedPriceMicrocredits,
}
amount, err = tokenEstimateAmount(pricing, tokenEstimate, 10_000)
quantity = tokenEstimate.InputTokens + tokenEstimate.OutputTokens
default:
return nil, BadAuthRequest("当前模型计费方式暂不支持")
}
if err != nil {
return nil, err
}
if amount <= 0 {
return nil, BadAuthRequest("当前模型尚未配置有效的用户价格")
}
return &LogicalModelQuote{
LogicalModelID: routed.LogicalModel.ID,
BillingMode: routed.LogicalModel.BillingMode,
Quantity: quantity,
AmountMicrocredits: amount,
Estimated: routed.LogicalModel.BillingMode == "token",
}, nil
}
func quoteInput(intent ModelRequestIntent, modelKey string) map[string]any {
config := make(map[string]any, len(intent.Options)+1)
for key, value := range intent.Options {
config[key] = value
}
config["model"] = modelKey
input := map[string]any{"mode": intent.Capability, "config": config}
if count := intent.Inputs["video"]; count > 0 {
references := make([]any, count)
input["referenceVideos"] = references
}
return input
}
func inputConfigValue(input map[string]any, key string) any {
config, _ := input["config"].(map[string]any)
if config == nil {
return nil
}
return config[key]
}

View File

@@ -3,6 +3,7 @@ package service
import (
"encoding/json"
"errors"
"fmt"
"math"
"regexp"
"sort"
@@ -18,22 +19,27 @@ import (
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"`
CapabilitySpec CapabilitySpec `json:"capabilitySpec"`
DefaultOptions map[string]any `json:"defaultOptions"`
Routes []LogicalRouteRequest `json:"routes"`
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 {
@@ -44,26 +50,41 @@ type LogicalRouteRequest struct {
}
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"`
BillingMode string `json:"billingMode"`
UnitPriceMicrocredits int64 `json:"unitPriceMicrocredits"`
InputPriceMicrocredits int64 `json:"inputPriceMicrocredits"`
OutputPriceMicrocredits int64 `json:"outputPriceMicrocredits"`
CachedPriceMicrocredits int64 `json:"cachedPriceMicrocredits"`
CapabilitySpec CapabilitySpec `json:"capabilitySpec"`
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"`
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"`
@@ -128,7 +149,7 @@ func (s *Service) PublicLogicalModels(intent *ModelRequestIntent) ([]PublicLogic
available = false
if coverageValid {
for _, route := range cached.Routes {
if route.Route.Enabled && route.Route.Weight > 0 && !s.logicalRouteBlocked(route) && MatchCapability(route.CapabilitySpec, resolvedIntent).Matched {
if route.Route.Enabled && route.Route.Weight > 0 && !s.logicalRouteBlocked(route) && MatchCapability(route.CapabilitySpec, resolvedIntent).Matched && (cached.Model.PricePolicy != "channel" || channelModelPriceTierForIntent(route.ChannelModel, resolvedIntent) != nil) {
available = true
break
}
@@ -159,7 +180,59 @@ func publicLogicalModel(cached cachedLogicalModel, available bool) PublicLogical
profiles = append(profiles, route.CapabilitySpec)
}
}
return PublicLogicalModel{ID: item.ID, Code: item.Code, Name: item.Name, Icon: item.Icon, Description: item.Description, Capability: item.Capability, SortOrder: item.SortOrder, PricePolicy: item.PricePolicy, BillingMode: item.BillingMode, UnitPriceMicrocredits: item.UnitPriceMicrocredits, InputPriceMicrocredits: item.InputPriceMicrocredits, OutputPriceMicrocredits: item.OutputPriceMicrocredits, CachedPriceMicrocredits: item.CachedPriceMicrocredits, CapabilitySpec: productSpec, CapabilityProfiles: profiles, DefaultOptions: cached.Defaults, Available: available}
return PublicLogicalModel{ID: item.ID, Code: item.Code, Name: item.Name, Icon: item.Icon, Description: item.Description, Capability: item.Capability, SortOrder: item.SortOrder, PricePolicy: item.PricePolicy, BillingMode: item.BillingMode, UnitPriceMicrocredits: item.UnitPriceMicrocredits, InputPriceMicrocredits: item.InputPriceMicrocredits, OutputPriceMicrocredits: item.OutputPriceMicrocredits, CachedPriceMicrocredits: item.CachedPriceMicrocredits, PriceTiers: publicLogicalModelPriceTiers(cached), LegacyModelIDs: decodeLegacyModelIDs(item.LegacyModelIDsJSON), CapabilitySpec: productSpec, CapabilityProfiles: profiles, DefaultOptions: cached.Defaults, Available: available}
}
func publicLogicalModelPriceTiers(cached cachedLogicalModel) []PublicLogicalModelPriceTier {
if cached.Model.PricePolicy != "channel" {
return []PublicLogicalModelPriceTier{}
}
result := make([]PublicLogicalModelPriceTier, 0)
seen := make(map[string]bool)
for _, route := range cached.Routes {
if !route.Route.Enabled || route.Route.Weight <= 0 {
continue
}
for _, tier := range route.ChannelModel.PriceTiers {
if !tier.Enabled || !tier.PriceConfigured {
continue
}
selector := skuSelectorForTier(tier)
_, selectorKey, selectorErr := model.CanonicalSKUSelector(selector)
if selectorErr != nil {
continue
}
key := fmt.Sprintf("%s:%s:%d:%d:%d:%d", selectorKey, tier.BillingMode, tier.UnitPriceMicrocredits, tier.InputTokenPriceMicrocredits, tier.OutputTokenPriceMicrocredits, tier.CachedTokenPriceMicrocredits)
if seen[key] {
continue
}
seen[key] = true
result = append(result, PublicLogicalModelPriceTier{Selector: selector, Resolution: normalizeChannelModelTierResolution(tier.Resolution), VideoSeconds: tier.VideoSeconds, BillingMode: tier.BillingMode, UnitPriceMicrocredits: tier.UnitPriceMicrocredits, InputTokenPriceMicrocredits: tier.InputTokenPriceMicrocredits, OutputTokenPriceMicrocredits: tier.OutputTokenPriceMicrocredits, CachedTokenPriceMicrocredits: tier.CachedTokenPriceMicrocredits})
}
}
return result
}
func decodeLegacyModelIDs(raw string) []string {
var values []string
if err := json.Unmarshal([]byte(raw), &values); err != nil {
return []string{}
}
return normalizeLegacyModelIDs(values)
}
func normalizeLegacyModelIDs(values []string) []string {
result := make([]string, 0, len(values))
seen := make(map[string]bool, len(values))
for _, raw := range values {
value := strings.TrimSpace(raw)
if value == "" || seen[value] {
continue
}
seen[value] = true
result = append(result, value)
}
return result
}
// capabilitySpecWithRoutePresets repairs old front-model snapshots that stored
@@ -423,6 +496,9 @@ func (s *Service) DeleteAdminLogicalModel(actor *model.User, id string) error {
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
@@ -450,6 +526,37 @@ func (s *Service) logicalModelBundle(actor *model.User, id string, req LogicalMo
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
@@ -498,8 +605,18 @@ func (s *Service) logicalModelBundle(actor *model.User, id string, req LogicalMo
}
}
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 {
@@ -629,7 +746,109 @@ func channelModelCapabilitySpec(channelModel model.ChannelModel) (CapabilitySpec
if err != nil {
return CapabilitySpec{}, BadAuthRequest("渠道模型能力配置无效,请先修复渠道模型")
}
return CapabilitySpecFromModelCapabilityConfig(config, normalizeCapability(channelModel.Capability))
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("渠道模型能力配置无效,请先修复渠道模型")
}
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) {

View File

@@ -554,7 +554,7 @@ func (s *Service) ValidateTaskCapability(input map[string]any) error {
}
return validateVideoTask(taskInput.Config.CapabilityConfig.Video, taskInput)
}
item, err := s.repo.ChannelModelByKey(channelID, strings.TrimPrefix(strings.TrimSpace(taskInput.Config.Model), "models/"))
item, err := s.repo.ChannelModelByKey(channelID, providerChannelModelKey(taskInput.Config))
if err != nil {
return BadAuthRequest("当前系统渠道模型未配置或已停用")
}
@@ -563,18 +563,32 @@ func (s *Service) ValidateTaskCapability(input map[string]any) error {
if err != nil {
return BadAuthRequest("当前图片模型能力参数无效")
}
imageProfile := DefaultImageCapabilityConfig(string(item.Protocol), item.ModelKey)
imageProfile := DefaultImageCapabilityConfig(string(item.Protocol), firstNonEmpty(item.ProviderModelKey, item.ModelKey))
if profile != nil && profile.Image != nil {
imageProfile = profile.Image
}
return validateImageTask(imageProfile, taskInput)
return validateImageTask(applyModelSpecificImageCapability(imageProfile, string(item.Protocol), firstNonEmpty(item.ProviderModelKey, item.ModelKey), taskInput.Config.APIFormat), taskInput)
}
if err != nil || profile == nil || profile.Video == nil {
return BadAuthRequest("当前视频模型尚未配置能力参数")
}
applyFixedVideoResolution(&taskInput, profile.Video)
if config, ok := input["config"].(map[string]any); ok {
config["vquality"] = taskInput.Config.VQuality
}
return validateVideoTask(profile.Video, taskInput)
}
// applyFixedVideoResolution 让单档位 SKU 的预扣、恢复和上游请求保持同一分辨率。
func applyFixedVideoResolution(input *canvasGenerationInput, profile *VideoCapabilityConfig) {
if input == nil || profile == nil || len(profile.Resolutions) != 1 {
return
}
if resolution := videoResolutionNameRequest(profile, profile.Resolutions[0]); resolution != "" {
input.Config.VQuality = resolution
}
}
func validateVideoTask(profile *VideoCapabilityConfig, input canvasGenerationInput) error {
if len(input.ReferenceImages) > profile.References.MaxImages || len(input.ReferenceVideos) > profile.References.MaxVideos || len(input.ReferenceAudios) > profile.References.MaxAudios {
return BadAuthRequest("参考素材数量超过当前模型限制")

View File

@@ -64,7 +64,8 @@ func ModelRequestIntentFromTaskInput(input map[string]any, taskType string, oper
if options, ok := input["capabilityOptions"].(map[string]any); ok {
explicitOptions = true
for key, value := range options {
intent.Options[canonicalCapabilityOptionName(key)] = value
name := canonicalCapabilityOptionName(key)
intent.Options[name] = normalizeModelRequestOption(name, value)
}
}
if config, ok := input["config"].(map[string]any); ok && !explicitOptions {
@@ -75,7 +76,7 @@ func ModelRequestIntentFromTaskInput(input map[string]any, taskType string, oper
default:
canonical := canonicalCapabilityOptionName(key)
if isCapabilityOptionFor(capability, canonical) && value != nil && strings.TrimSpace(fmt.Sprint(value)) != "" {
intent.Options[canonical] = value
intent.Options[canonical] = normalizeModelRequestOption(canonical, value)
}
}
}
@@ -83,6 +84,30 @@ func ModelRequestIntentFromTaskInput(input map[string]any, taskType string, oper
return intent
}
func normalizeModelRequestOption(name string, value any) any {
if canonicalCapabilityOptionName(name) != "vquality" {
return value
}
resolution, ok := value.(string)
if !ok {
return value
}
switch strings.ToLower(strings.TrimSpace(resolution)) {
case "low", "480", "480p":
return "480p"
case "720", "720p":
return "720p"
case "1080", "1080p":
return "1080p"
case "2k", "1440", "1440p":
return "1440p"
case "4k", "2160", "2160p":
return "2160p"
default:
return value
}
}
type CapabilityMatch struct {
Matched bool `json:"matched"`
Reasons []string `json:"reasons,omitempty"`
@@ -114,6 +139,7 @@ type RoutedModel struct {
Revision model.LogicalModelRevision
Route model.LogicalModelRoute
ChannelModel model.ChannelModel
PriceTier *model.ChannelModelPriceTier
Defaults map[string]any
}
@@ -540,7 +566,7 @@ func (s *Service) loadRouteCatalog() (*routeCatalogSnapshot, error) {
if item.PricePolicy == "unified" && item.BillingMode == "token" && !supportsTokenBilling(item.Capability, channelModel.Protocol) {
continue
}
if item.PricePolicy == "channel" && !channelModel.PriceConfigured {
if item.PricePolicy == "channel" && !channelModelHasActivePriceTier(channelModel) {
continue
}
capabilitySpec, specErr := channelModelCapabilitySpec(channelModel)
@@ -581,15 +607,22 @@ func (s *Service) ResolveLogicalModel(logicalModelID string, intent ModelRequest
if match := MatchCapability(cached.ProductSpec, intent); !match.Matched {
return nil, BadAuthRequest("所选模型不支持当前请求:" + strings.Join(match.Reasons, ";"))
}
eligible := s.eligibleLogicalRoutes(cached.Routes, intent, nil)
eligible := s.eligibleLogicalRoutes(cached.Routes, intent, nil, cached.Model.PricePolicy == "channel")
if len(eligible) == 0 {
return nil, BadAuthRequest("当前模型暂时无法满足这组输入和参数")
}
selected := weightedRoute(eligible)
return &RoutedModel{LogicalModel: cached.Model, Revision: cached.Revision, Route: selected.Route, ChannelModel: selected.ChannelModel, Defaults: cached.Defaults}, nil
var priceTier *model.ChannelModelPriceTier
if cached.Model.PricePolicy == "channel" {
priceTier = channelModelPriceTierForIntent(selected.ChannelModel, intent)
if priceTier == nil {
return nil, BadAuthRequest("当前模型尚未配置所选规格的价格")
}
}
return &RoutedModel{LogicalModel: cached.Model, Revision: cached.Revision, Route: selected.Route, ChannelModel: selected.ChannelModel, PriceTier: priceTier, Defaults: cached.Defaults}, nil
}
func (s *Service) eligibleLogicalRoutes(routes []cachedLogicalRoute, intent ModelRequestIntent, tried map[string]bool) []cachedLogicalRoute {
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 {
@@ -599,6 +632,9 @@ func (s *Service) eligibleLogicalRoutes(routes []cachedLogicalRoute, intent Mode
if match := MatchCapability(route.CapabilitySpec, intent); !match.Matched {
continue
}
if requirePriceTier && channelModelPriceTierForIntent(route.ChannelModel, intent) == nil {
continue
}
if route.Route.Priority > maxPriority {
eligible = eligible[:0]
maxPriority = route.Route.Priority
@@ -610,6 +646,88 @@ func (s *Service) eligibleLogicalRoutes(routes []cachedLogicalRoute, intent Mode
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) *model.ChannelModelPriceTier {
selector := skuSelectorForIntent(intent)
bestScore := -1
var best *model.ChannelModelPriceTier
for index := range channelModel.PriceTiers {
tier := &channelModel.PriceTiers[index]
if !tier.Enabled || !tier.PriceConfigured {
continue
}
matched, score := matchSKUSelector(skuSelectorForTier(*tier), selector)
if !matched {
continue
}
if score > bestScore {
best, bestScore = tier, score
}
}
return best
}
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":
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)
}
case "image":
for _, key := range []string{"quality", "size"} {
if value := strings.ToLower(strings.TrimSpace(fmt.Sprint(intent.Options[key]))); value != "" && value != "auto" && value != "any" {
selector[key] = value
}
}
}
return selector
}
func skuSelectorForTier(tier model.ChannelModelPriceTier) map[string]string {
selector := model.DecodeSKUSelector(tier.SelectorJSON)
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}
@@ -906,7 +1024,7 @@ func (s *Service) switchTaskToNextRoute(task *model.Task, attempts []model.Route
}
channelModelByID := make(map[string]model.ChannelModel, len(channelModels))
for _, channelModel := range channelModels {
if channelModel.Enabled && enabledSystemChannels[channelModel.ChannelID] && (logicalModel.PricePolicy != "channel" || channelModel.PriceConfigured) {
if channelModel.Enabled && enabledSystemChannels[channelModel.ChannelID] && (logicalModel.PricePolicy != "channel" || channelModelHasActivePriceTier(channelModel)) {
channelModelByID[channelModel.ID] = channelModel
}
}
@@ -926,12 +1044,19 @@ func (s *Service) switchTaskToNextRoute(task *model.Task, attempts []model.Route
for _, attempt := range attempts {
tried[attempt.RouteID] = true
}
eligible := s.eligibleLogicalRoutes(candidates, intent, tried)
eligible := s.eligibleLogicalRoutes(candidates, intent, tried, logicalModel.PricePolicy == "channel")
if len(eligible) == 0 {
return nil, BadAuthRequest("当前模型暂时无法满足这组输入和参数")
}
selected := weightedRoute(eligible)
routed := &RoutedModel{LogicalModel: *logicalModel, Revision: *revision, Route: selected.Route, ChannelModel: selected.ChannelModel, Defaults: defaults}
var priceTier *model.ChannelModelPriceTier
if logicalModel.PricePolicy == "channel" {
priceTier = channelModelPriceTierForIntent(selected.ChannelModel, intent)
if priceTier == nil {
return nil, BadAuthRequest("当前模型尚未配置所选规格的价格")
}
}
routed := &RoutedModel{LogicalModel: *logicalModel, Revision: *revision, Route: selected.Route, ChannelModel: selected.ChannelModel, PriceTier: priceTier, Defaults: defaults}
nextInput := applyRoutedProviderSelection(input, routed)
if err := s.ValidateTaskCapability(nextInput); err != nil {
return nil, err
@@ -945,12 +1070,12 @@ func (s *Service) switchTaskToNextRoute(task *model.Task, attempts []model.Route
}
var replacement *model.BillingOrder
if logicalModel.PricePolicy == "channel" && task.BillingOrderID != "" {
config, _ := input["config"].(map[string]any)
capability := normalizeCapability(fmt.Sprint(input["mode"]))
config, _ := nextInput["config"].(map[string]any)
capability := normalizeCapability(fmt.Sprint(nextInput["mode"]))
if capability == "" {
capability = capabilityFromTaskType(task.Type)
}
replacement, err = s.newBillingOrder(task.UserID, task.ID, "route-switch:"+task.ID+":"+selected.Route.ID, selected.ChannelModel.ChannelID, selected.ChannelModel.ModelKey, capability, firstNonEmpty(strings.TrimSpace(task.Operation), task.Type), billingQuantity(capability, config["videoSeconds"]), estimateTaskBillingTokens(input, capability))
replacement, err = s.newBillingOrderWithPriceTier(task.UserID, task.ID, "route-switch:"+task.ID+":"+selected.Route.ID, selected.ChannelModel.ChannelID, selected.ChannelModel.ModelKey, capability, firstNonEmpty(strings.TrimSpace(task.Operation), task.Type), billingQuantity(capability, config["videoSeconds"]), estimateTaskBillingTokens(nextInput, capability), strings.TrimSpace(fmt.Sprint(config["priceTierId"])))
if err != nil {
return nil, err
}

View File

@@ -0,0 +1,14 @@
package service
import "testing"
func TestModelRequestIntentNormalizesVideoResolution(t *testing.T) {
input := map[string]any{
"mode": "video",
"config": map[string]any{"vquality": "480", "videoSeconds": "6", "size": "16:9"},
}
intent := ModelRequestIntentFromTaskInput(input, "video_generate", "text_to_video")
if got := intent.Options["vquality"]; got != "480p" {
t.Fatalf("vquality = %#v, want 480p", got)
}
}

View File

@@ -54,6 +54,9 @@ type providerTextMessage struct {
type providerConfig struct {
ChannelID string `json:"channelId"`
ChannelModelKey string `json:"channelModelKey,omitempty"`
PriceTierID string `json:"priceTierId,omitempty"`
ProviderModelKey string `json:"providerModelKey,omitempty"`
APIFormat string `json:"apiFormat"`
InterfaceType string `json:"interfaceType"`
BaseURL string `json:"baseUrl"`
@@ -167,7 +170,7 @@ func withProviderAnalytics(ctx context.Context, service *Service, task model.Tas
}
if json.Unmarshal([]byte(task.InputJSON), &input) == nil {
metadata.ChannelID = firstNonEmpty(input.Config.ChannelID, systemChannelIDFromBaseURL(input.Config.BaseURL))
metadata.Model = firstNonEmpty(input.Config.Model, metadata.Model)
metadata.Model = firstNonEmpty(input.Config.ChannelModelKey, input.Config.Model, metadata.Model)
metadata.VideoSeconds, _ = strconv.Atoi(input.Config.VideoSeconds)
if normalized := normalizeCapability(input.Mode); normalized != "" {
metadata.Capability = normalized
@@ -660,7 +663,7 @@ func (s *Service) validateResolvedVideoCapability(input *canvasGenerationInput)
input.VideoCapability = input.Config.CapabilityConfig.Video
return validateVideoTask(input.VideoCapability, *input)
}
item, err := s.repo.ChannelModelByKey(channelID, strings.TrimPrefix(strings.TrimSpace(input.Config.Model), "models/"))
item, err := s.repo.ChannelModelByKey(channelID, providerChannelModelKey(input.Config))
if err != nil {
return errors.New("当前系统渠道模型未配置或已停用")
}
@@ -669,6 +672,7 @@ func (s *Service) validateResolvedVideoCapability(input *canvasGenerationInput)
return errors.New("当前视频模型尚未配置能力参数")
}
input.VideoCapability = profile.Video
applyFixedVideoResolution(input, profile.Video)
return validateVideoTask(profile.Video, *input)
}
@@ -683,7 +687,7 @@ func (s *Service) validateResolvedImageCapability(input *canvasGenerationInput)
}
return validateImageTask(input.ImageCapability, *input)
}
item, err := s.repo.ChannelModelByKey(channelID, strings.TrimPrefix(strings.TrimSpace(input.Config.Model), "models/"))
item, err := s.repo.ChannelModelByKey(channelID, providerChannelModelKey(input.Config))
if err != nil {
return errors.New("当前系统渠道模型未配置或已停用")
}
@@ -815,15 +819,19 @@ func (s *Service) resolveProviderConfig(config providerConfig) (providerConfig,
if err != nil {
return providerConfig{}, errors.New("系统渠道不存在或已停用")
}
modelName := strings.TrimSpace(config.Model)
if modelName == "" {
modelKey := strings.TrimPrefix(strings.TrimSpace(config.ChannelModelKey), "models/")
requestedModel := strings.TrimPrefix(strings.TrimSpace(config.Model), "models/")
if modelKey == "" {
modelKey = requestedModel
}
if modelKey == "" {
models := channelModelNames(*channel)
if len(models) == 0 {
return providerConfig{}, errors.New("系统渠道未配置可用模型")
}
modelName = models[0]
modelKey = models[0]
}
if !stringInSlice(modelName, channelModelNames(*channel)) {
if !stringInSlice(modelKey, channelModelNames(*channel)) {
return providerConfig{}, errors.New("当前系统渠道未授权该模型")
}
if _, err := s.validateChannelOutboundURL(channel.BaseURL, channel.AllowLocalChannel, false); err != nil {
@@ -831,10 +839,31 @@ func (s *Service) resolveProviderConfig(config providerConfig) (providerConfig,
}
config.ChannelID = channel.ID
config.APIFormat = channel.APIFormat
channelModel, modelErr := s.repo.ChannelModelByKey(channel.ID, modelName)
if modelErr != nil || channelModel.Protocol == "" {
channelModel, modelErr := s.repo.ChannelModelByKey(channel.ID, modelKey)
if modelErr != nil {
// ModelsJSON 只是旧渠道表上的目录缓存。SKU 合并后它不能代表可执行模型,
// 唯一授权来源必须是已启用的 channel_models 记录。
return providerConfig{}, errors.New("当前系统渠道未授权该模型")
}
if channelModel.Protocol == "" {
return providerConfig{}, errors.New("当前模型尚未配置请求协议")
}
providerModelKey := strings.TrimPrefix(strings.TrimSpace(config.ProviderModelKey), "models/")
if config.PriceTierID != "" {
matched := false
for _, tier := range channelModel.PriceTiers {
if tier.ID == config.PriceTierID && tier.Enabled && tier.PriceConfigured {
providerModelKey = firstNonEmpty(providerModelKey, tier.ProviderModelKey)
matched = true
break
}
}
if !matched {
return providerConfig{}, errors.New("当前模型规格价格档已更新,请重新创建任务")
}
} else if modelKey != "" && requestedModel != "" && modelKey != requestedModel {
return providerConfig{}, errors.New("系统渠道模型标识不一致")
}
config.InterfaceType = string(channelModel.Protocol)
// 模型协议是实际请求契约;混合渠道中鉴权格式也必须随模型协议切换。
if config.InterfaceType == string(model.ChannelInterfaceGeminiVeo) || config.InterfaceType == string(model.ChannelInterfaceGeminiImage) {
@@ -850,18 +879,14 @@ func (s *Service) resolveProviderConfig(config providerConfig) (providerConfig,
if err != nil {
return providerConfig{}, err
}
config.Model = modelName
config.ChannelModelKey = modelKey
config.ProviderModelKey = providerModelKey
config.Model = firstNonEmpty(providerModelKey, channelModel.ProviderModelKey, modelKey)
return config, nil
}
func stringInSlice(value string, values []string) bool {
value = strings.TrimPrefix(strings.TrimSpace(value), "models/")
for _, candidate := range values {
if strings.TrimPrefix(strings.TrimSpace(candidate), "models/") == value {
return true
}
}
return false
func providerChannelModelKey(config providerConfig) string {
return strings.TrimPrefix(strings.TrimSpace(firstNonEmpty(config.ChannelModelKey, config.Model)), "models/")
}
func systemChannelIDFromBaseURL(baseURL string) string {

View File

@@ -187,8 +187,8 @@ func TestRunAgentToolTaskFallsBackToolChoice(t *testing.T) {
}))
defer server.Close()
config := providerConfig{BaseURL: server.URL, APIKey: "key", Model: "thinking-model", AllowLocalChannel: true}
result, err := runAgentToolTask(withProviderOutboundPolicy(context.Background(), config), canvasGenerationInput{
config := providerConfig{BaseURL: server.URL, APIKey: "key", Model: "thinking-model"}
result, err := runAgentToolTask(context.Background(), canvasGenerationInput{
Config: config,
AgentRequests: &agentToolRequests{ChatCompletion: map[string]interface{}{"messages": []interface{}{}, "tool_choice": "required"}},
})

View File

@@ -116,7 +116,7 @@ func applyRoutedProviderSelection(input map[string]any, routed *RoutedModel) map
nextConfig := make(map[string]any, len(config)+2)
for key, value := range config {
switch key {
case "channelId", "apiFormat", "interfaceType", "baseUrl", "allowLocalChannel", "apiKey", "secretKey", "headers", "model", "capabilityConfig":
case "channelId", "channelModelKey", "priceTierId", "providerModelKey", "apiFormat", "interfaceType", "baseUrl", "allowLocalChannel", "apiKey", "secretKey", "headers", "model", "capabilityConfig":
continue
default:
nextConfig[key] = value
@@ -139,6 +139,11 @@ func applyRoutedProviderSelection(input map[string]any, routed *RoutedModel) map
}
nextConfig["channelId"] = routed.ChannelModel.ChannelID
nextConfig["model"] = routed.ChannelModel.ModelKey
nextConfig["channelModelKey"] = routed.ChannelModel.ModelKey
if routed.PriceTier != nil {
nextConfig["priceTierId"] = routed.PriceTier.ID
nextConfig["providerModelKey"] = routed.PriceTier.ProviderModelKey
}
input["config"] = nextConfig
return input
}

View File

@@ -3,9 +3,9 @@ import { ConfigProvider, Switch } from "antd";
import { type CanvasTheme } from "@/lib/canvas-theme";
import { buildImageResolutionOptions, formatImageResolutionSize, imageRatioForSize, imageResolutionChoices, imageResolutionOption, imageSizeForResolution, supportsImageResolutionPresets, type ImageResolutionChoice } from "@/lib/image-resolution-tiers";
import { normalizeImageValue, type ImageCapabilityConfig } from "@/lib/model-capabilities";
import { modelCapabilityConfigFor, normalizeImageValue, type ImageCapabilityConfig } from "@/lib/model-capabilities";
import { mergedImageCapabilityConfig } from "@/lib/model-selection";
import { type AiConfig } from "@/stores/use-config-store";
import { modelOptionName, resolveModelChannel, type AiConfig } from "@/stores/use-config-store";
const qualityOptions = [
{ value: "auto", label: "自动" },
@@ -74,7 +74,8 @@ export function ImageSettingsPanel({ config, onConfigChange, theme, showTitle =
: imageAspectOptions(profile);
const selectedAspect = availableAspects.find((item) => imageOptionValue(profile, item) === activeSize || item.value === activeSize) || availableAspects.find((item) => item.label === activeRatio);
const dimensions = readSizeDimensions(activeSize, selectedAspect || aspectOptions[0]);
const activeQualityOptions = profile.quality.values.map((value) => qualityOptions.find((item) => item.value === value) || { value, label: value });
const activeQualityOptions = profile.quality.values.map((value) => qualityOptions.find((item) => item.value === value) || { value, label: value });
const priceTiers = imageModelPriceTiers(config);
const selectAspect = (value: string) => {
const option = availableAspects.find((item) => item.value === value);
onConfigChange("size", option ? imageOptionValue(profile, option) : "auto");
@@ -110,8 +111,8 @@ export function ImageSettingsPanel({ config, onConfigChange, theme, showTitle =
{profile.quality.supported ? <div className="space-y-2">
<SettingTitle color={theme.node.muted}>{isGrokResolutionQuality(profile) ? "分辨率" : "质量"}</SettingTitle>
<div className={`grid gap-1.5 ${activeQualityOptions.length <= 2 ? "grid-cols-2" : "grid-cols-4"}`}>
{activeQualityOptions.map((item) => (
<OptionPill key={item.value} selected={quality === item.value} theme={theme} onClick={() => onConfigChange("quality", item.value)}>
{activeQualityOptions.map((item) => (
<OptionPill key={item.value} selected={quality === item.value} disabled={!hasPriceTierForImageSelection(priceTiers, item.value, activeSize)} theme={theme} onClick={() => onConfigChange("quality", item.value)}>
{item.label}
</OptionPill>
))}
@@ -262,12 +263,27 @@ export function imageSizeLabel(size: string) {
return resolutionLabel !== size ? resolutionLabel : aspectOptions.find((item) => (item.size || item.value) === size || item.value === size)?.label || size;
}
function OptionPill({ selected, theme, onClick, children }: { selected: boolean; theme: CanvasTheme; onClick: () => void; children: ReactNode }) {
function imageModelPriceTiers(config: AiConfig) {
const channel = resolveModelChannel(config, config.model || config.imageModel);
const cost = channel.modelCosts?.find((item) => item.model === modelOptionName(config.model || config.imageModel));
return cost?.logicalPriceTiers || [];
}
function hasPriceTierForImageSelection(tiers: ReturnType<typeof imageModelPriceTiers>, quality: string, size: string) {
if (!tiers.length) return true;
return tiers.some((tier) => {
const selector = tier.selector || {};
return (!selector.quality || selector.quality === "*" || selector.quality === quality.toLowerCase()) && (!selector.size || selector.size === "*" || selector.size === size.toLowerCase());
});
}
function OptionPill({ selected, disabled = false, theme, onClick, children }: { selected: boolean; disabled?: boolean; theme: CanvasTheme; onClick: () => void; children: ReactNode }) {
return (
<button
type="button"
className="h-8 cursor-pointer rounded-full px-2 text-xs transition-colors hover:brightness-110 focus-visible:outline focus-visible:outline-1 focus-visible:outline-offset-1"
style={{ background: selected ? theme.toolbar.activeBg : "transparent", color: theme.node.text, outlineColor: theme.node.muted }}
className="h-8 cursor-pointer rounded-full px-2 text-xs transition-colors hover:brightness-110 focus-visible:outline focus-visible:outline-1 focus-visible:outline-offset-1 disabled:cursor-not-allowed disabled:opacity-40"
style={{ background: selected ? theme.toolbar.activeBg : "transparent", color: theme.node.text, outlineColor: theme.node.muted }}
disabled={disabled}
onMouseDown={(event) => event.stopPropagation()}
onClick={onClick}
>

View File

@@ -4,12 +4,14 @@ import { Popover } from "antd";
import { canvasThemes, type CanvasTheme } from "@/lib/canvas-theme";
import { modelCapabilityConfigFor, videoDurationOptions } from "@/lib/model-capabilities";
import { compatibleModelInGroup, configuredModelDisplayName, groupModelsByDisplayName, modelCompatibilityError, resolveCompatibleModel, type ModelRequirements } from "@/lib/model-selection";
import { compatibleModelInGroup, configuredModelDisplayName, groupModelsByDisplayName, modelCompatibilityError, modelRequestOptions, resolveCompatibleModel, type ModelRequirements } from "@/lib/model-selection";
import { normalizeVideoResolution } from "@/lib/video-generation-options";
import { cn } from "@/lib/utils";
import { modelDisplayName, modelIcon, modelOptionName, PUBLIC_MODEL_CATALOG_ID, resolveModelChannel, selectableModelsByCapability, type AiConfig, type ModelCapability } from "@/stores/use-config-store";
import { useThemeStore } from "@/stores/use-theme-store";
import { useUserStore } from "@/stores/use-user-store";
import { ModelLogo } from "@/components/model-logo";
import { quoteLogicalModel, type LogicalModelQuote, type ModelRequestIntent } from "@/services/api/logical-models";
type ModelPickerProps = {
config: AiConfig;
@@ -53,12 +55,31 @@ export function ModelPicker({ config, value, onChange, capability, className, fu
return channelGroups;
}, [config, options]);
const storedCurrent = value?.trim() || "";
const resolvedCurrent = resolveCompatibleModel(config, storedCurrent, requirements) || storedCurrent;
// 参数档位会在选中模型后由调用方归一到其能力配置,不能因为旧模型留下的参数而禁止切换。
const selectionRequirements = requirements ? { ...requirements, videoSeconds: undefined, imageSize: undefined, options: undefined } : undefined;
const resolvedCurrent = resolveCompatibleModel(config, storedCurrent, selectionRequirements) || storedCurrent;
// 旧画布可能保存过已下架或前端历史内置模型;它们不能重新进入当前可选目录。
const current = options.includes(resolvedCurrent) ? resolvedCurrent : "";
const currentPrice = modelMenuPrice(config, current);
const currentPrice = modelMenuPrice(config, current, capability);
const quoteRequest = useMemo(() => modelQuoteRequest(config, current, capability, requirements), [capability, config, current, requirements]);
const [routeQuote, setRouteQuote] = useState<LogicalModelQuote | undefined>();
const creationVariant = variant === "creation";
useEffect(() => {
if (!showSelectedPrice || !creditsEnabled || !quoteRequest) {
setRouteQuote(undefined);
return;
}
const controller = new AbortController();
setRouteQuote(undefined);
quoteLogicalModel(quoteRequest.logicalModelID, quoteRequest.intent, controller.signal)
.then((payload) => setRouteQuote(payload.quote))
.catch(() => {
if (!controller.signal.aborted) setRouteQuote(undefined);
});
return () => controller.abort();
}, [creditsEnabled, quoteRequest, showSelectedPrice]);
useEffect(() => {
const closeOtherPicker = (event: Event) => {
if ((event as CustomEvent<string>).detail !== pickerId) setOpen(false);
@@ -141,26 +162,28 @@ export function ModelPicker({ config, value, onChange, capability, className, fu
<div className="grid min-w-0 gap-1">
{group.models.map((modelGroup) => {
const selected = modelGroup.models.includes(current);
const compatibleModel = compatibleModelInGroup(config, modelGroup.models, requirements, selected ? current : undefined);
const model = compatibleModel || modelGroup.models[0];
const displayModel = model;
const incompatibleReason = compatibleModel ? "" : modelCompatibilityError(config, modelGroup.models[0], requirements) || "当前输入不符合该模型能力,切换后将重置参数";
const model = compatibleModelInGroup(config, modelGroup.models, selectionRequirements, selected ? current : undefined);
const displayModel = model || (selected ? current : modelGroup.models[0]);
const disabledReason = model ? "" : modelCompatibilityError(config, modelGroup.models[0], selectionRequirements) || "当前输入不符合该模型能力";
return (
<button
key={modelGroup.key}
type="button"
role="option"
aria-selected={selected}
title={incompatibleReason || pickerModelOptionLabel(config, displayModel, showConfiguredModelName)}
className="canvas-model-picker-option"
style={{ background: "transparent", color: theme.node.text }}
aria-disabled={Boolean(disabledReason)}
disabled={Boolean(disabledReason)}
title={disabledReason || pickerModelOptionLabel(config, displayModel, showConfiguredModelName)}
className="canvas-model-picker-option disabled:cursor-not-allowed disabled:opacity-45"
style={{ background: selected ? theme.toolbar.activeBg : "transparent", color: theme.node.text }}
onClick={() => {
if (!model) return;
onChange(model);
setOpen(false);
window.requestAnimationFrame(() => triggerRef.current?.focus());
}}
>
<ModelLabel config={config} model={displayModel} capability={capability} theme={theme} creationVariant={creationVariant} showConfiguredModelName={showConfiguredModelName} showPrice={creditsEnabled} disabledReason={incompatibleReason} />
<ModelLabel config={config} model={displayModel} capability={capability} theme={theme} creationVariant={creationVariant} showConfiguredModelName={showConfiguredModelName} showPrice={creditsEnabled} disabledReason={disabledReason} />
{selected ? <Check className="canvas-model-picker-option-check" style={{ color: theme.node.activeStroke }} /> : null}
</button>
);
@@ -206,7 +229,7 @@ export function ModelPicker({ config, value, onChange, capability, className, fu
<ModelIcon config={config} model={current} />
</span>
<span className="min-w-0 flex-1 truncate">{current ? (creationVariant ? pickerModelDisplayName(config, current, showConfiguredModelName) : pickerModelOptionLabel(config, current, showConfiguredModelName)) : placeholder}</span>
{showSelectedPrice && creditsEnabled ? <ModelPrice price={currentPrice} compact /> : null}
{showSelectedPrice && creditsEnabled ? <ModelPrice price={currentPrice} quote={routeQuote} compact /> : null}
</span>
<ChevronDown className={cn("canvas-model-picker-chevron", open && "is-open")} aria-hidden="true" />
</button>
@@ -257,7 +280,7 @@ function ModelLabel({
{capabilitySummary}
</span>
</span>
{showPrice ? <ModelPrice price={modelMenuPrice(config, model)} /> : null}
{showPrice ? <ModelPrice price={modelMenuPrice(config, model, capability, true)} /> : null}
{!creationVariant && meta.time ? (
<span className="shrink-0 rounded-full px-1.5 py-0.5 text-[var(--fs-tiny)] tabular-nums" style={{ background: theme.toolbar.itemHover, color: theme.node.muted }}>
{meta.time}
@@ -326,17 +349,24 @@ function formatDurationSummary(profile: NonNullable<ReturnType<typeof modelCapab
return `${profile.duration.min || values[0]}-${profile.duration.max || values[values.length - 1]}s`;
}
type ModelMenuPrice = { value: number; unit: "次" | "秒" | "百万 Token" };
type ModelMenuPrice =
| { kind: "tiers"; label: string; compactLabel: string; title: string }
| { kind: "estimate" }
| { kind: "fixed"; value: number; unit: "次" | "秒" | "百万 Token" };
function modelMenuPrice(config: AiConfig, model: string): ModelMenuPrice | null | undefined {
function modelMenuPrice(config: AiConfig, model: string, capability?: ModelCapability, summary = false): ModelMenuPrice | null | undefined {
if (!model) return undefined;
const channel = resolveModelChannel(config, model);
const cost = channel.modelCosts?.find((item) => item.model === modelOptionName(model));
if (!cost) return channel.scope === "system" ? null : undefined;
if (cost.billingMode === "token") {
return { value: (cost.outputTokenPriceMicrocredits || 0) / 1_000_000, unit: "百万 Token" };
if (cost.pricePolicy === "channel") {
const tiers = cost.logicalPriceTiers || [];
if (!tiers.length) return null;
const matched = summary ? tiers : priceTiersForCurrentSelection(tiers, capability, config);
return channelTierPriceSummary(matched.length ? matched : tiers, tiers);
}
return { value: cost.unitPriceMicrocredits / 1_000_000, unit: cost.billingMode === "per_second" ? "秒" : "次" };
if (cost.billingMode === "token") return { kind: "estimate" };
return { kind: "fixed", value: cost.unitPriceMicrocredits / 1_000_000, unit: cost.billingMode === "per_second" ? "秒" : "次" };
}
function pickerModelDisplayName(config: AiConfig, model: string, showConfiguredModelName: boolean) {
@@ -349,9 +379,131 @@ function pickerModelOptionLabel(config: AiConfig, model: string, showConfiguredM
return channel.scope === "system" ? displayName : `${displayName}(${channel.name})`;
}
function ModelPrice({ price, compact = false }: { price: ModelMenuPrice | null | undefined; compact?: boolean }) {
function priceTiersForCurrentSelection(
tiers: NonNullable<NonNullable<AiConfig["channels"][number]["modelCosts"]>[number]["logicalPriceTiers"]>,
capability: ModelCapability | undefined,
config: AiConfig,
) {
const requested: Record<string, string> = {};
if (capability === "video") {
const resolution = normalizeTierResolution(config.vquality);
if (resolution !== "*") requested.vquality = resolution;
const seconds = Math.max(0, Math.floor(Number(config.videoSeconds) || 0));
if (seconds > 0) requested.videoSeconds = String(seconds);
}
if (capability === "image") {
if (config.quality && config.quality !== "auto") requested.quality = config.quality.toLowerCase();
if (config.size && config.size !== "auto") requested.size = config.size.toLowerCase();
}
let bestScore = -1;
let matched: typeof tiers = [];
for (const tier of tiers) {
const selector = tier.selector || {};
const conditions = Object.entries(selector).filter(([, value]) => value && value !== "*");
if (conditions.some(([key, value]) => requested[key] !== value)) continue;
const score = conditions.length;
if (score > bestScore) {
bestScore = score;
matched = [tier];
} else if (score === bestScore) {
matched.push(tier);
}
}
return matched;
}
function normalizeTierResolution(value: string) {
const raw = String(value || "").trim();
if (!raw || raw === "*") return "*";
return `${normalizeVideoResolution(raw)}p`;
}
function channelTierPriceSummary(
visibleTiers: NonNullable<NonNullable<AiConfig["channels"][number]["modelCosts"]>[number]["logicalPriceTiers"]>,
allTiers: NonNullable<NonNullable<AiConfig["channels"][number]["modelCosts"]>[number]["logicalPriceTiers"]>,
): Extract<ModelMenuPrice, { kind: "tiers" }> {
const fixedRequestValues = visibleTiers
.filter((tier) => tier.billingMode === "fixed_request")
.map((tier) => tier.unitPriceMicrocredits / 1_000_000)
.filter((value) => value > 0);
const perSecondValues = visibleTiers
.filter((tier) => tier.billingMode === "per_second")
.map((tier) => tier.unitPriceMicrocredits / 1_000_000)
.filter((value) => value > 0);
const hasTokenTier = visibleTiers.some((tier) => tier.billingMode === "token");
const label = fixedRequestValues.length
? formatPriceRange(fixedRequestValues, "积分")
: perSecondValues.length
? formatPriceRange(perSecondValues, "积分/秒")
: hasTokenTier
? "按量预估"
: "未配置";
return {
kind: "tiers",
label,
compactLabel: label,
title: `系统规格价格:${allTiers.map((tier) => `${tierSpecificationLabel(tier)} ${tierPriceLabel(tier)}`).join(";")}`,
};
}
function formatPriceRange(values: number[], suffix: string) {
const unique = Array.from(new Set(values)).sort((left, right) => left - right);
const format = (value: number) => value.toLocaleString("zh-CN", { maximumFractionDigits: 3 });
return unique.length === 1 ? `${format(unique[0])} ${suffix}` : `${format(unique[0])}-${format(unique[unique.length - 1])} ${suffix}`;
}
function tierResolutionLabel(value: string) {
const normalized = normalizeTierResolution(value);
return normalized === "*" ? "全部分辨率" : normalized.toUpperCase();
}
function tierDurationLabel(seconds: number) {
return seconds > 0 ? `${seconds} 秒` : "全部时长";
}
function tierSpecificationLabel(tier: NonNullable<NonNullable<AiConfig["channels"][number]["modelCosts"]>[number]["logicalPriceTiers"]>[number]) {
const selector = tier.selector || {};
const operationLabels: Record<string, string> = { text_to_image: "文生图", image_to_image: "图生图", text_to_video: "文生视频", image_to_video: "图生视频", video_to_video: "视频生视频" };
const operation = selector.operation && selector.operation !== "*" ? (operationLabels[selector.operation] || selector.operation) : "";
const details = [
operation,
selector.quality && selector.quality !== "*" ? selector.quality.toUpperCase() : "",
selector.size && selector.size !== "*" ? selector.size : "",
tier.resolution !== "*" ? tierResolutionLabel(tier.resolution) : "",
tier.videoSeconds ? tierDurationLabel(tier.videoSeconds) : "",
].filter(Boolean);
return details.length ? details.join(" / ") : "默认规格";
}
function tierPriceLabel(tier: NonNullable<NonNullable<AiConfig["channels"][number]["modelCosts"]>[number]["logicalPriceTiers"]>[number]) {
if (tier.billingMode === "token") return "按量预估";
return `${formatPriceRange([tier.unitPriceMicrocredits / 1_000_000], tier.billingMode === "per_second" ? "积分/秒" : "积分")}`;
}
function ModelPrice({ price, quote, compact = false }: { price: ModelMenuPrice | null | undefined; quote?: LogicalModelQuote; compact?: boolean }) {
if (quote) {
const amount = (quote.amountMicrocredits / 1_000_000).toLocaleString("zh-CN", { maximumFractionDigits: 3 });
const label = quote.estimated ? `预计 ${amount}` : `${amount}`;
return (
<span className="inline-flex shrink-0 items-center gap-0.5 text-[var(--fs-tiny)] font-bold tabular-nums text-amber-600 dark:text-amber-300" title={`${quote.estimated ? "预计" : "本次"}消耗 ${amount} 积分`}>
<Coins className="size-3" />
{compact ? label : `${label} 积分`}
</span>
);
}
if (price === undefined) return null;
if (price === null) return compact ? null : <span className="shrink-0 text-[var(--fs-tiny)] text-foreground/40">未配置</span>;
if (price.kind === "tiers") {
return (
<span className="inline-flex shrink-0 items-center gap-0.5 text-[var(--fs-tiny)] font-bold tabular-nums text-amber-600 dark:text-amber-300" title={price.title}>
<Coins className="size-3" />
{compact ? price.compactLabel : price.label}
</span>
);
}
if (price.kind === "estimate") {
return <span className="shrink-0 text-[var(--fs-tiny)] font-medium text-amber-600 dark:text-amber-300">按量预估</span>;
}
return (
<span className="inline-flex shrink-0 items-center gap-0.5 text-[var(--fs-tiny)] font-bold tabular-nums text-amber-600 dark:text-amber-300" title={`每${price.unit}消耗 ${price.value.toLocaleString("zh-CN", { maximumFractionDigits: 6 })} 积分`}>
<Coins className="size-3" />
@@ -360,6 +512,31 @@ function ModelPrice({ price, compact = false }: { price: ModelMenuPrice | null |
);
}
function modelQuoteRequest(config: AiConfig, value: string, capability?: ModelCapability, requirements?: ModelRequirements): { logicalModelID: string; intent: ModelRequestIntent } | undefined {
if (!capability || !value) return undefined;
const channel = resolveModelChannel(config, value);
if (channel.scope !== "system") return undefined;
const cost = channel.modelCosts?.find((item) => item.model === modelOptionName(value));
if (!cost?.logicalModelId) return undefined;
const input = requirements?.input;
const intent: ModelRequestIntent = {
capability,
operation: requirements?.videoOperation,
inputs: {
image: (input?.imageCount || 0) + (input?.characterCount || 0),
video: input?.videoCount || 0,
audio: input?.audioCount || 0,
},
options: {
...modelRequestOptions(config, capability),
...(requirements?.options || {}),
...(requirements?.videoSeconds ? { videoSeconds: Number(requirements.videoSeconds) } : {}),
...(requirements?.imageSize ? { size: requirements.imageSize } : {}),
},
};
return { logicalModelID: cost.logicalModelId, intent };
}
function modelMenuMeta(model: string, capability?: ModelCapability): { description: string; time?: string } {
const name = modelOptionName(model).toLowerCase();
if (capability === "image") {

View File

@@ -6,7 +6,7 @@ import { boolConfig, isSeedanceFastModel, isSeedanceVideoConfig, normalizeSeedan
import { type CanvasTheme } from "@/lib/canvas-theme";
import { normalizeVideoDuration, normalizeVideoResolution, VIDEO_DURATION_MIN } from "@/lib/video-generation-options";
import { modelCapabilityConfigFor, videoDurationOptions, type VideoCapabilityConfig } from "@/lib/model-capabilities";
import { modelOptionName, resolveModelRequestConfig, type AiConfig } from "@/stores/use-config-store";
import { modelOptionName, resolveModelChannel, resolveModelRequestConfig, type AiConfig } from "@/stores/use-config-store";
const sizeOptions = [
{ value: "1280x720", label: "横屏", width: 1280, height: 720 },
@@ -27,11 +27,12 @@ type VideoSettingsPanelProps = {
export function VideoSettingsPanel({ config, onConfigChange, theme, showTitle = true, className = "w-[292px] space-y-3" }: VideoSettingsPanelProps) {
const profile = modelCapabilityConfigFor(config, config.model).video!;
const priceTiers = modelPriceTiers(config);
if (resolveModelRequestConfig(config, config.model).interfaceType === "volcengine-jimeng-video") {
return <JiMengVideoSettingsPanel config={config} profile={profile} onConfigChange={onConfigChange} theme={theme} showTitle={showTitle} className={className} />;
return <JiMengVideoSettingsPanel config={config} profile={profile} priceTiers={priceTiers} onConfigChange={onConfigChange} theme={theme} showTitle={showTitle} className={className} />;
}
if (isSeedanceVideoConfig(config)) {
return <SeedanceVideoSettingsPanel config={config} profile={profile} onConfigChange={onConfigChange} theme={theme} showTitle={showTitle} className={className} />;
return <SeedanceVideoSettingsPanel config={config} profile={profile} priceTiers={priceTiers} onConfigChange={onConfigChange} theme={theme} showTitle={showTitle} className={className} />;
}
const seconds = normalizeVideoDuration(config.videoSeconds);
@@ -53,7 +54,7 @@ export function VideoSettingsPanel({ config, onConfigChange, theme, showTitle =
{configuredResolutions.length ? <SettingGroup title="分辨率" color={theme.node.muted}>
<div className="grid grid-cols-3 gap-1.5">
{configuredResolutions.map((item) => (
<OptionPill key={item.value} selected={resolution === item.value} theme={theme} onClick={() => onConfigChange("vquality", item.value)}>
<OptionPill key={item.value} selected={resolution === item.value} disabled={!hasPriceTierForVideoSelection(priceTiers, item.value, Number(seconds))} theme={theme} onClick={() => onConfigChange("vquality", item.value)}>
{item.label}
</OptionPill>
))}
@@ -82,7 +83,7 @@ export function VideoSettingsPanel({ config, onConfigChange, theme, showTitle =
</div>
</SettingGroup>
<SettingGroup title="秒数" color={theme.node.muted}>
<VideoDurationControl profile={profile} value={Number(seconds)} theme={theme} onChange={(value) => onConfigChange("videoSeconds", String(value))} />
<VideoDurationControl profile={profile} value={Number(seconds)} theme={theme} disabled={(value) => !hasPriceTierForVideoSelection(priceTiers, resolution, value)} onChange={(value) => onConfigChange("videoSeconds", String(value))} />
</SettingGroup>
{profile.generateAudio.supported || profile.watermark.supported ? <SettingGroup title="输出" color={theme.node.muted}><div className="grid grid-cols-2 gap-3 rounded-md px-2" style={{ background: theme.toolbar.itemHover }}>{profile.generateAudio.supported ? <SwitchRow label="生成声音" checked={generateAudio} theme={theme} onChange={(checked) => onConfigChange("videoGenerateAudio", String(checked))} /> : null}{profile.watermark.supported ? <SwitchRow label="添加水印" checked={watermark} theme={theme} onChange={(checked) => onConfigChange("videoWatermark", String(checked))} /> : null}</div></SettingGroup> : null}
</div>
@@ -90,7 +91,7 @@ export function VideoSettingsPanel({ config, onConfigChange, theme, showTitle =
);
}
function JiMengVideoSettingsPanel({ config, profile, onConfigChange, theme, showTitle, className }: VideoSettingsPanelProps & { profile: VideoCapabilityConfig }) {
function JiMengVideoSettingsPanel({ config, profile, priceTiers, onConfigChange, theme, showTitle, className }: VideoSettingsPanelProps & { profile: VideoCapabilityConfig; priceTiers: ReturnType<typeof modelPriceTiers> }) {
const seconds = normalizeVideoDuration(config.videoSeconds);
return (
<ImageSettingsTheme theme={theme}>
@@ -102,14 +103,14 @@ function JiMengVideoSettingsPanel({ config, profile, onConfigChange, theme, show
</div>
</SettingGroup>
<SettingGroup title="秒数" color={theme.node.muted}>
<VideoDurationControl profile={profile} value={Number(seconds)} theme={theme} onChange={(value) => onConfigChange("videoSeconds", String(value))} />
<VideoDurationControl profile={profile} value={Number(seconds)} theme={theme} disabled={(value) => !hasPriceTierForVideoSelection(priceTiers, "*", value)} onChange={(value) => onConfigChange("videoSeconds", String(value))} />
</SettingGroup>
</div>
</ImageSettingsTheme>
);
}
function SeedanceVideoSettingsPanel({ config, profile, onConfigChange, theme, showTitle, className }: VideoSettingsPanelProps & { profile: VideoCapabilityConfig }) {
function SeedanceVideoSettingsPanel({ config, profile, priceTiers, onConfigChange, theme, showTitle, className }: VideoSettingsPanelProps & { profile: VideoCapabilityConfig; priceTiers: ReturnType<typeof modelPriceTiers> }) {
const model = modelOptionName(config.model || config.videoModel);
const resolution = normalizeSeedanceResolution(config.vquality, model);
const ratio = normalizeSeedanceRatio(config.size);
@@ -125,7 +126,7 @@ function SeedanceVideoSettingsPanel({ config, profile, onConfigChange, theme, sh
<div className="grid grid-cols-3 gap-1.5">
{profile.resolutions.map((value) => {
const item = { value, label: value.toUpperCase() };
const disabled = item.value === "1080p" && isSeedanceFastModel(model);
const disabled = (item.value === "1080p" && isSeedanceFastModel(model)) || !hasPriceTierForVideoSelection(priceTiers, item.value, duration);
return (
<OptionPill key={item.value} selected={resolution === item.value} disabled={disabled} theme={theme} onClick={() => onConfigChange("vquality", item.value)}>
{item.label}
@@ -158,7 +159,7 @@ function SeedanceVideoSettingsPanel({ config, profile, onConfigChange, theme, sh
</div>
</SettingGroup>
<SettingGroup title="时长" color={theme.node.muted}>
<VideoDurationControl profile={profile} value={duration} theme={theme} onChange={(value) => onConfigChange("videoSeconds", String(value))} />
<VideoDurationControl profile={profile} value={duration} theme={theme} disabled={(value) => !hasPriceTierForVideoSelection(priceTiers, resolution, value)} onChange={(value) => onConfigChange("videoSeconds", String(value))} />
</SettingGroup>
<SettingGroup title="输出" color={theme.node.muted}>
<div className="grid grid-cols-2 gap-3 rounded-md px-2" style={{ background: theme.toolbar.itemHover }}>
@@ -256,21 +257,44 @@ function DurationInput({ value, min, max, theme, onChange }: { value: number; mi
);
}
function VideoDurationControl({ profile, value, theme, onChange }: { profile: VideoCapabilityConfig; value: number; theme: CanvasTheme; onChange: (value: number) => void }) {
function VideoDurationControl({ profile, value, theme, disabled, onChange }: { profile: VideoCapabilityConfig; value: number; theme: CanvasTheme; disabled?: (value: number) => boolean; onChange: (value: number) => void }) {
if (profile.duration.selection === "range") {
const min = profile.duration.min || VIDEO_DURATION_MIN;
const max = Math.max(min, profile.duration.max || min);
const step = Math.max(1, profile.duration.step || 1);
const normalized = normalizeDurationValue(value, profile.duration.default, min, max, step);
return <DurationRangeControl value={normalized} min={min} max={max} step={step} theme={theme} onChange={onChange} />;
return <DurationRangeControl value={normalized} min={min} max={max} step={step} theme={theme} onChange={(next) => { if (!disabled?.(next)) onChange(next); }} />;
}
const options = videoDurationOptions(profile);
return <div className="grid gap-1.5" style={{ gridTemplateColumns: `repeat(${Math.min(options.length, 4)}, minmax(0, 1fr))` }}>
{options.map((option) => <OptionPill key={option} selected={normalizedNumber(value) === option} theme={theme} onClick={() => onChange(option)}>{option}s</OptionPill>)}
{options.map((option) => <OptionPill key={option} selected={normalizedNumber(value) === option} disabled={disabled?.(option)} theme={theme} onClick={() => onChange(option)}>{option}s</OptionPill>)}
</div>;
}
function modelPriceTiers(config: AiConfig) {
const channel = resolveModelChannel(config, config.model);
const cost = channel.modelCosts?.find((item) => item.model === modelOptionName(config.model));
return cost?.logicalPriceTiers || [];
}
function hasPriceTierForVideoSelection(tiers: ReturnType<typeof modelPriceTiers>, resolution: string, seconds: number) {
if (!tiers.length) return true;
const normalizedResolution = normalizeTierResolution(resolution);
return tiers.some((tier) => {
const selector = tier.selector || {};
const tierResolution = selector.vquality || tier.resolution;
const tierSeconds = selector.videoSeconds ? Number(selector.videoSeconds) : tier.videoSeconds;
return (tierResolution === "*" || !tierResolution || normalizeTierResolution(tierResolution) === normalizedResolution) && (!tierSeconds || tierSeconds === seconds);
});
}
function normalizeTierResolution(value: string) {
const normalized = normalizeVideoResolution(value);
if (/^\d+$/.test(normalized)) return `${normalized}p`;
return normalized.toLowerCase();
}
function DurationRangeControl({ value, min, max, step, theme, onChange }: { value: number; min: number; max: number; step: number; theme: CanvasTheme; onChange: (value: number) => void }) {
return <div className="space-y-1.5">
<div className="flex min-w-0 items-center gap-2">

View File

@@ -145,7 +145,10 @@ function logicalModelCompatibilityError(spec: NonNullable<NonNullable<AiConfig["
}
function logicalOptionMatches(name: string, constraint: { values?: unknown[]; min?: number; max?: number; step?: number }, value: unknown) {
if (constraint.values?.length) return constraint.values.some((candidate) => logicalOptionValueMatches(name, candidate, value));
if (constraint.values?.length) {
const requested = normalizeLogicalOptionValue(name, value);
return constraint.values.some((candidate) => normalizeLogicalOptionValue(name, candidate) === requested);
}
const numeric = Number(value);
if (!Number.isFinite(numeric)) return false;
if (constraint.min !== undefined && numeric < constraint.min) return false;
@@ -154,11 +157,15 @@ function logicalOptionMatches(name: string, constraint: { values?: unknown[]; mi
return true;
}
function logicalOptionValueMatches(name: string, candidate: unknown, value: unknown) {
const left = String(candidate).trim().toLowerCase();
const right = String(value).trim().toLowerCase();
if (name === "vquality" || name === "resolution") return left.replace(/p$/, "") === right.replace(/p$/, "");
return left === right;
function normalizeLogicalOptionValue(name: string, value: unknown) {
const normalized = String(value).trim().toLowerCase();
if (name !== "vquality" && name !== "resolution") return normalized;
if (normalized === "low") return "480p";
if (["auto", "medium", "high"].includes(normalized)) return "720p";
if (normalized === "2k") return "1440p";
if (normalized === "4k") return "2160p";
const resolution = normalized.replace(/p$/i, "");
return resolution ? `${resolution}p` : "";
}
function logicalOptionError(name: string) {

View File

@@ -81,6 +81,9 @@ function managedModelChannels(models: PublicLogicalModel[]) {
scope: "system",
enabled: true,
models: availableModels.map((item) => item.id),
modelAliases: Object.fromEntries(
availableModels.flatMap((item) => (item.legacyModelIds || []).map((legacyID) => [legacyID, item.id])),
),
modelCosts: availableModels.map((item) => ({
model: item.id,
displayName: item.name,
@@ -97,6 +100,7 @@ function managedModelChannels(models: PublicLogicalModel[]) {
logicalModelId: item.id,
logicalCapabilitySpec: item.capabilitySpec,
logicalCapabilityProfiles: item.capabilityProfiles,
logicalPriceTiers: item.priceTiers,
defaultOptions: item.defaultOptions,
})),
};

View File

@@ -1,7 +1,7 @@
import { useEffect, useState } from "react";
import { App, Button, Drawer, Form, Input, InputNumber, Popconfirm, Segmented, Select, Space, Switch } from "antd";
import { App, Button, Drawer, Form, Input, InputNumber, Popconfirm, Segmented, Select, Space, Switch, type FormInstance } from "antd";
import type { ColumnsType } from "antd/es/table";
import { FlaskConical, Plus, RefreshCw, Search, Trash2 } from "lucide-react";
import { FlaskConical, Plus, RefreshCw, Search, Trash2, X } from "lucide-react";
import { PaginationBar } from "@/components/layout/workspace-page";
import { ModelIcon } from "@/components/model-picker";
@@ -9,7 +9,7 @@ import { ModelCapabilityEditor } from "@/components/model-capability-editor";
import { CapabilityCardPicker, ProtocolCardPicker, type ModelCapabilityChoice } from "@/components/model-protocol-picker";
import { defaultModelCapabilityConfig, normalizeModelCapabilityConfig, type ModelCapabilityConfig } from "@/lib/model-capabilities";
import { MODEL_PROTOCOLS, modelProtocolCapability, modelProtocolDefinition, modelProtocolLabel, modelProtocolSupportsTokenBilling, type ModelProtocol } from "@/lib/model-protocols";
import { createAdminChannelModel, deleteAdminChannelModel, fetchAdminChannelModels, listAdminChannelModels, testAdminChannelModel, updateAdminChannelModel, type ChannelModel } from "@/services/api/wallet";
import { createAdminChannelModel, deleteAdminChannelModel, fetchAdminChannelModels, listAdminChannelModels, testAdminChannelModel, updateAdminChannelModel, type ChannelModel, type ChannelModelPriceTier } from "@/services/api/wallet";
import type { ModelChannel } from "@/stores/use-config-store";
import { AdminPageFrame } from "./admin-shell";
import { AdminDataTable, AdminFilterChip, AdminStatusBadge } from "./admin-ui";
@@ -18,16 +18,29 @@ type EditableCapability = ModelCapabilityChoice;
type FormValues = {
modelKey: string;
providerModelKey?: string;
displayName?: string;
capability: EditableCapability;
protocol: ModelProtocol;
priceTiers: PriceTierFormValues[];
enabled: boolean;
capabilityConfig?: ModelCapabilityConfig;
};
type PriceTierFormValues = {
operation: string;
quality: string;
size: string;
resolution: string;
videoSeconds: number;
providerModelKey?: string;
billingMode: ChannelModel["billingMode"];
unitPrice: number;
inputTokenPrice: number;
outputTokenPrice: number;
cachedTokenPrice: number;
priceConfigured: boolean;
enabled: boolean;
capabilityConfig?: ModelCapabilityConfig;
};
export function ChannelModelManager({ channel, onClose, onChanged }: { channel: ModelChannel; onClose: () => void; onChanged: () => void | Promise<void> }) {
@@ -45,10 +58,11 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel:
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(20);
const [form] = Form.useForm<FormValues>();
const billingMode = Form.useWatch("billingMode", form) || "fixed_request";
const modelCapability = Form.useWatch("capability", form);
const modelProtocol = Form.useWatch("protocol", form);
const modelKey = Form.useWatch("modelKey", form) || "";
const providerModelKey = Form.useWatch("providerModelKey", form) || "";
const capabilityConfig = Form.useWatch("capabilityConfig", form);
const reload = async () => {
if (!channel) return;
@@ -93,14 +107,11 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel:
setEditing(null);
form.setFieldsValue({
modelKey: "",
providerModelKey: "",
displayName: "",
capability: "text",
protocol: "chat-completion",
billingMode: "fixed_request",
unitPrice: 0,
inputTokenPrice: 0,
outputTokenPrice: 0,
cachedTokenPrice: 0,
priceTiers: [defaultPriceTier()],
enabled: true,
capabilityConfig: defaultModelCapabilityConfig("chat-completion", ""),
});
@@ -111,37 +122,48 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel:
setEditing(item);
form.setFieldsValue({
modelKey: item.modelKey,
providerModelKey: item.providerModelKey || item.modelKey,
displayName: item.displayName,
capability: item.capability || undefined,
protocol: item.protocol,
billingMode: item.billingMode,
unitPrice: item.unitPriceMicrocredits / 1_000_000,
inputTokenPrice: item.inputTokenPriceMicrocredits / 1_000_000,
outputTokenPrice: item.outputTokenPriceMicrocredits / 1_000_000,
cachedTokenPrice: item.cachedTokenPriceMicrocredits / 1_000_000,
priceTiers: item.priceTiers?.length ? item.priceTiers.map(priceTierToForm) : [legacyPriceTierToForm(item)],
enabled: item.enabled,
capabilityConfig: item.capability === "text" || item.capability === "image" || item.capability === "video" ? normalizeModelCapabilityConfig(item.capabilityConfig || defaultModelCapabilityConfig(item.protocol, item.modelKey)) : undefined,
capabilityConfig: item.capability === "text" || item.capability === "image" || item.capability === "video"
? normalizeModelCapabilityConfig(item.capabilityConfig, item.protocol, item.providerModelKey || item.modelKey, channel.apiFormat)
: undefined,
});
setEditorOpen(true);
};
const save = async () => {
const values = await form.validateFields();
const upstreamModel = values.providerModelKey?.trim() || values.modelKey.trim();
const capabilityConfig = values.capability === "text" || values.capability === "image" || values.capability === "video"
? normalizeModelCapabilityConfig(values.capabilityConfig, values.protocol, upstreamModel, channel.apiFormat)
: undefined;
setSaving(true);
try {
const payload = {
modelKey: values.modelKey.trim(),
providerModelKey: upstreamModel,
displayName: values.displayName?.trim() || values.modelKey.trim(),
capability: values.capability,
protocol: values.protocol,
billingMode: values.billingMode,
unitPriceMicrocredits: Math.round(values.unitPrice * 1_000_000),
inputTokenPriceMicrocredits: Math.round((values.inputTokenPrice || 0) * 1_000_000),
outputTokenPriceMicrocredits: Math.round((values.outputTokenPrice || 0) * 1_000_000),
cachedTokenPriceMicrocredits: Math.round((values.cachedTokenPrice || 0) * 1_000_000),
priceConfigured: true,
priceTiers: values.priceTiers.map((tier) => ({
selector: skuSelectorFromForm(values.capability, tier),
resolution: values.capability === "video" ? (tier.resolution || "*") : "*",
videoSeconds: values.capability === "video" ? Number(tier.videoSeconds || 0) : 0,
providerModelKey: tier.providerModelKey?.trim() || upstreamModel,
billingMode: tier.billingMode,
unitPriceMicrocredits: Math.round((tier.unitPrice || 0) * 1_000_000),
inputTokenPriceMicrocredits: Math.round((tier.inputTokenPrice || 0) * 1_000_000),
outputTokenPriceMicrocredits: Math.round((tier.outputTokenPrice || 0) * 1_000_000),
cachedTokenPriceMicrocredits: Math.round((tier.cachedTokenPrice || 0) * 1_000_000),
priceConfigured: tier.priceConfigured !== false,
enabled: tier.enabled !== false,
})),
enabled: values.enabled !== false,
capabilityConfig: values.capability === "text" || values.capability === "image" || values.capability === "video" ? normalizeModelCapabilityConfig(values.capabilityConfig!) : undefined,
capabilityConfig,
};
if (editing) await updateAdminChannelModel(channel.id, editing.id, payload);
else await createAdminChannelModel(channel.id, payload);
@@ -158,14 +180,19 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel:
};
const testModel = async () => {
const values = await form.validateFields(["modelKey", "capability", "protocol", ...(modelCapability === "text" || modelCapability === "image" || modelCapability === "video" ? ["capabilityConfig"] : [])]);
const values = await form.validateFields(["modelKey", "providerModelKey", "capability", "protocol", ...(modelCapability === "text" || modelCapability === "image" || modelCapability === "video" ? ["capabilityConfig"] : [])]);
const upstreamModel = values.providerModelKey?.trim() || values.modelKey.trim();
const capabilityConfig = values.capability === "text" || values.capability === "image" || values.capability === "video"
? normalizeModelCapabilityConfig(values.capabilityConfig, values.protocol, upstreamModel, channel.apiFormat)
: undefined;
setTesting(true);
try {
const result = await testAdminChannelModel(channel.id, {
modelKey: values.modelKey.trim(),
providerModelKey: upstreamModel,
capability: values.capability,
protocol: values.protocol,
capabilityConfig: values.capabilityConfig ? normalizeModelCapabilityConfig(values.capabilityConfig) : undefined,
capabilityConfig,
});
message.success(`模型测试通过,耗时 ${(result.durationMs / 1000).toFixed(2)} 秒`);
} catch (error) {
@@ -203,6 +230,16 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel:
form.setFieldValue("protocol", nextProtocol);
form.setFieldValue("capabilityConfig", changed.capability === "text" || changed.capability === "image" || changed.capability === "video" ? defaultModelCapabilityConfig(nextProtocol, form.getFieldValue("modelKey")) : undefined);
}
const nextTiers = (form.getFieldValue("priceTiers") || []).map((tier: PriceTierFormValues) => ({
...tier,
operation: tier.operation || "*",
quality: changed.capability === "image" ? tier.quality || "*" : "*",
size: changed.capability === "image" ? tier.size || "*" : "*",
resolution: changed.capability === "video" ? tier.resolution || "*" : "*",
videoSeconds: changed.capability === "video" ? tier.videoSeconds || 0 : 0,
billingMode: tier.billingMode === "per_second" && changed.capability !== "video" ? "fixed_request" : tier.billingMode,
}));
form.setFieldValue("priceTiers", nextTiers);
};
const columns: ColumnsType<ChannelModel> = [
@@ -216,6 +253,7 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel:
<div className="min-w-0">
<div className="truncate font-medium">{item.displayName || item.modelKey}</div>
<div className="admin-monospace truncate text-xs text-foreground/45">{item.modelKey}</div>
{item.providerModelKey && item.providerModelKey !== item.modelKey ? <div className="admin-monospace truncate text-xs text-foreground/35">上游:{item.providerModelKey}</div> : null}
</div>
</div>
),
@@ -235,7 +273,7 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel:
<AdminStatusBadge label="待配置" tone="warning" />
),
},
{ title: "计费", width: 220, render: (_, item) => (item.priceConfigured ? billingSummary(item) : <AdminStatusBadge label="未配置价格" tone="warning" />) },
{ title: "规格价格", width: 280, render: (_, item) => (item.priceConfigured ? billingSummary(item) : <AdminStatusBadge label="未配置价格" tone="warning" />) },
{ title: "版本", dataIndex: "priceVersion", width: 75, render: (value) => `v${value}` },
{ title: "状态", dataIndex: "enabled", width: 85, render: (enabled) => <AdminStatusBadge label={enabled ? "启用" : "停用"} tone={enabled ? "success" : "neutral"} /> },
{
@@ -256,7 +294,7 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel:
const filteredItems = items.filter((item) => {
const query = keyword.trim().toLowerCase();
if (query && !`${item.modelKey} ${item.displayName}`.toLowerCase().includes(query)) return false;
if (query && !`${item.modelKey} ${item.providerModelKey} ${item.displayName}`.toLowerCase().includes(query)) return false;
if (capability !== "all" && item.capability !== capability) return false;
if (status === "enabled" && !item.enabled) return false;
if (status === "disabled" && item.enabled) return false;
@@ -361,17 +399,20 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel:
<div className="mb-4">
<h2 className="text-sm font-semibold">模型身份</h2>
</div>
<div className="grid gap-4 md:grid-cols-2">
<Form.Item name="modelKey" label="模型标识" rules={[{ required: true, message: "请输入模型标识" }]}>
<div className="grid gap-4 md:grid-cols-3">
<Form.Item name="modelKey" label="产品模型标识" rules={[{ required: true, message: "请输入产品模型标识" }]}>
<Input
prefix={
<span className="grid size-6 place-items-center">
<ModelIcon model={modelKey} />
</span>
}
placeholder="例如:deepseek-chat、gpt-5、glm-4.5"
placeholder="例如:seedance-2-5"
/>
</Form.Item>
<Form.Item name="providerModelKey" label="上游模型 ID">
<Input placeholder="留空则使用产品模型标识" />
</Form.Item>
<Form.Item name="displayName" label="后台显示名称">
<Input placeholder="不填则使用模型标识" />
</Form.Item>
@@ -392,48 +433,35 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel:
</div>
{modelCapability === "text" || modelCapability === "image" || modelCapability === "video" ? (
<Form.Item name="capabilityConfig" rules={[{ required: true, message: `请配置${capabilityLabel(modelCapability)}能力参数` }]}>
<ModelCapabilityEditor capability={modelCapability} model={modelKey} protocol={form.getFieldValue("protocol")} />
<ModelCapabilityEditor capability={modelCapability} model={providerModelKey || modelKey} protocol={form.getFieldValue("protocol")} />
</Form.Item>
) : null}
</section>
<section className="admin-form-section">
<div className="mb-4">
<h2 className="text-sm font-semibold">计费</h2>
<h2 className="text-sm font-semibold">规格价格档</h2>
</div>
<Form.Item name="billingMode" label="计费方式" rules={[{ required: true }]}>
<Segmented
block
options={[
{ label: "按次计费", value: "fixed_request" },
{ label: "按秒计费", value: "per_second", disabled: modelCapability !== "video" },
{ label: "Token 计费", value: "token", disabled: !modelProtocolSupportsTokenBilling(modelCapability, modelProtocol) },
]}
/>
</Form.Item>
{billingMode === "token" ? (
modelCapability === "video" ? (
<Form.Item name="outputTokenPrice" label="视频 / 百万 Token" rules={[{ required: true, message: "请输入视频 Token 价格" }]}>
<InputNumber style={{ width: "100%" }} min={0.000001} max={1_000_000} precision={6} step={0.1} />
</Form.Item>
) : (
<div className="grid gap-3 sm:grid-cols-3">
<Form.Item name="inputTokenPrice" label="输入 / 百万 Token" rules={[{ required: true, message: "请输入输入价格" }]}>
<InputNumber style={{ width: "100%" }} min={0} max={1_000_000} precision={6} step={0.1} />
</Form.Item>
<Form.Item name="outputTokenPrice" label="输出 / 百万 Token" rules={[{ required: true, message: "请输入输出价格" }]}>
<InputNumber style={{ width: "100%" }} min={0} max={1_000_000} precision={6} step={0.1} />
</Form.Item>
<Form.Item name="cachedTokenPrice" label="缓存 / 百万 Token" rules={[{ required: true, message: "请输入缓存价格" }]}>
<InputNumber style={{ width: "100%" }} min={0} max={1_000_000} precision={6} step={0.1} />
</Form.Item>
</div>
)
) : (
<Form.Item name="unitPrice" label={billingMode === "per_second" ? "每秒消耗积分" : "每次消耗积分"} rules={[{ required: true, message: "请输入积分价格" }]}>
<InputNumber style={{ width: "100%" }} min={0} max={1_000_000} precision={6} step={0.1} />
</Form.Item>
)}
<Form.List name="priceTiers" rules={[{ validator: async (_, value) => { if (!value?.length) throw new Error("请至少配置一个价格档"); } }]}>
{(fields, { add, remove }, { errors }) => (
<div className="space-y-2">
{fields.map((field, index) => (
<PriceTierFields
key={field.key}
index={field.name}
ordinal={index + 1}
form={form}
capability={modelCapability}
protocol={modelProtocol}
capabilityConfig={capabilityConfig}
onRemove={() => remove(field.name)}
/>
))}
<Button icon={<Plus className="size-4" />} onClick={() => add(defaultPriceTier())}>新增价格档</Button>
<Form.ErrorList errors={errors} />
</div>
)}
</Form.List>
</section>
<section className="admin-form-section">
@@ -454,25 +482,172 @@ function capabilityLabel(value: ChannelModel["capability"]) {
return { text: "文本", image: "图片", video: "视频", audio: "音频", "": "待配置" }[value];
}
function billingSummary(item: ChannelModel) {
if (item.billingMode !== "token") {
return `${formatCredits(item.unitPriceMicrocredits)} 积分 / ${item.billingMode === "per_second" ? "秒" : "次"}`;
}
function PriceTierFields({
index,
ordinal,
form,
capability,
protocol,
capabilityConfig,
onRemove,
}: {
index: number;
ordinal: number;
form: FormInstance<FormValues>;
capability: EditableCapability | undefined;
protocol: ModelProtocol | undefined;
capabilityConfig?: ModelCapabilityConfig;
onRemove: () => void;
}) {
const billingMode = Form.useWatch(["priceTiers", index, "billingMode"], form) || "fixed_request";
const video = capabilityConfig?.video;
const resolutionOptions = video?.resolutions || [];
const durationOptions = video?.duration.selection === "enum" ? video.duration.values || [] : [];
const tokenEnabled = Boolean(capability && protocol && modelProtocolSupportsTokenBilling(capability, protocol));
const isVideo = capability === "video";
const isImage = capability === "image";
const selectorColumnClass = isVideo || isImage ? "lg:col-span-3" : "lg:col-span-6";
const controlsColumnClass = billingMode === "token" && !isVideo ? "lg:col-span-3" : "lg:col-span-5";
return (
<div className="text-xs leading-5">
{item.capability === "video" ? (
<div>视频 {formatCredits(item.outputTokenPriceMicrocredits)} / 百万</div>
) : (
<>
<div>输入 {formatCredits(item.inputTokenPriceMicrocredits)} / 百万</div>
<div>输出 {formatCredits(item.outputTokenPriceMicrocredits)} / 百万</div>
<div>缓存 {formatCredits(item.cachedTokenPriceMicrocredits)} / 百万</div>
</>
)}
<div className="rounded-md border border-border bg-muted/10 p-3">
<div className="mb-2 flex items-center justify-between gap-3">
<div className="text-sm font-medium">价格档 {ordinal}</div>
<Button type="text" danger aria-label={`删除价格档 ${ordinal}`} title="删除价格档" icon={<X className="size-4" />} onClick={onRemove} />
</div>
<div className="grid gap-3 lg:grid-cols-12">
<Form.Item className={`mb-0 ${selectorColumnClass}`} name={[index, "operation"]} label="生成方式" rules={[{ required: true, message: "请选择生成方式" }]}>
<Select options={operationOptions(capability)} />
</Form.Item>
{isVideo ? (
<Form.Item className="mb-0 lg:col-span-3" name={[index, "resolution"]} label="分辨率" rules={[{ required: true, message: "请选择分辨率" }]}>
<Select options={[{ label: "任意分辨率", value: "*" }, ...resolutionOptions.map((value) => ({ label: value.toUpperCase(), value }))]} />
</Form.Item>
) : null}
{isVideo ? (
<Form.Item className="mb-0 lg:col-span-3" name={[index, "videoSeconds"]} label="时长" rules={[{ required: true, message: "请输入时长" }]}>
{durationOptions.length ? <Select options={[{ label: "任意时长", value: 0 }, ...durationOptions.map((value) => ({ label: `${value} 秒`, value }))]} /> : <InputNumber className="w-full" min={0} precision={0} />}
</Form.Item>
) : null}
{isImage ? (
<Form.Item className="mb-0 lg:col-span-3" name={[index, "quality"]} label="质量/分辨率" rules={[{ required: true, message: "请选择质量或分辨率" }]}>
<Select options={[{ label: "任意质量", value: "*" }, { label: "1K", value: "1k" }, { label: "2K", value: "2k" }, { label: "4K", value: "4k" }]} />
</Form.Item>
) : null}
{isImage ? (
<Form.Item className="mb-0 lg:col-span-3" name={[index, "size"]} label="画幅/尺寸">
<Input placeholder="任意,或 1:1、16:9、1024x1024" />
</Form.Item>
) : null}
<Form.Item className={`mb-0 ${selectorColumnClass}`} name={[index, "providerModelKey"]} label="上游模型 ID">
<Input placeholder="留空则使用模型默认上游 ID" />
</Form.Item>
</div>
<div className="grid items-end gap-3 lg:grid-cols-12">
<Form.Item className="mb-0 lg:col-span-4" name={[index, "billingMode"]} label="计费方式" rules={[{ required: true }]}>
<Segmented
className="w-full"
options={[
{ label: "按次", value: "fixed_request" },
{ label: "按秒", value: "per_second", disabled: !isVideo },
{ label: "Token", value: "token", disabled: !tokenEnabled },
]}
/>
</Form.Item>
{billingMode === "token" ? (
isVideo ? (
<Form.Item className="mb-0 lg:col-span-3" name={[index, "outputTokenPrice"]} label="视频 / 百万 Token" rules={[{ required: true, message: "请输入视频 Token 价格" }]}>
<InputNumber className="w-full" min={0.000001} max={1_000_000} precision={6} step={0.1} />
</Form.Item>
) : (
<div className="grid gap-3 sm:grid-cols-3 lg:col-span-5">
<Form.Item className="mb-0" name={[index, "inputTokenPrice"]} label="输入 / 百万 Token" rules={[{ required: true, message: "请输入输入价格" }]}><InputNumber className="w-full" min={0} max={1_000_000} precision={6} step={0.1} /></Form.Item>
<Form.Item className="mb-0" name={[index, "outputTokenPrice"]} label="输出 / 百万 Token" rules={[{ required: true, message: "请输入输出价格" }]}><InputNumber className="w-full" min={0} max={1_000_000} precision={6} step={0.1} /></Form.Item>
<Form.Item className="mb-0" name={[index, "cachedTokenPrice"]} label="缓存 / 百万 Token" rules={[{ required: true, message: "请输入缓存价格" }]}><InputNumber className="w-full" min={0} max={1_000_000} precision={6} step={0.1} /></Form.Item>
</div>
)
) : (
<Form.Item className="mb-0 lg:col-span-3" name={[index, "unitPrice"]} label={billingMode === "per_second" ? "每秒消耗积分" : "每次消耗积分"} rules={[{ required: true, message: "请输入积分价格" }]}>
<InputNumber className="w-full" min={0} max={1_000_000} precision={6} step={0.1} />
</Form.Item>
)}
<div className={`flex items-center gap-6 ${controlsColumnClass}`}>
<Form.Item name={[index, "priceConfigured"]} label="价格已配置" valuePropName="checked" className="mb-0"><Switch /></Form.Item>
<Form.Item name={[index, "enabled"]} label="启用此价格档" valuePropName="checked" className="mb-0"><Switch /></Form.Item>
</div>
</div>
</div>
);
}
function defaultPriceTier(): PriceTierFormValues {
return { operation: "*", quality: "*", size: "*", resolution: "*", videoSeconds: 0, providerModelKey: "", billingMode: "fixed_request", unitPrice: 0, inputTokenPrice: 0, outputTokenPrice: 0, cachedTokenPrice: 0, priceConfigured: true, enabled: true };
}
function priceTierToForm(tier: ChannelModelPriceTier): PriceTierFormValues {
return {
operation: tier.selector?.operation || "*", quality: tier.selector?.quality || "*", size: tier.selector?.size || "*",
resolution: tier.resolution || "*", videoSeconds: tier.videoSeconds || 0, providerModelKey: tier.providerModelKey || "", billingMode: tier.billingMode,
unitPrice: tier.unitPriceMicrocredits / 1_000_000, inputTokenPrice: tier.inputTokenPriceMicrocredits / 1_000_000,
outputTokenPrice: tier.outputTokenPriceMicrocredits / 1_000_000, cachedTokenPrice: tier.cachedTokenPriceMicrocredits / 1_000_000,
priceConfigured: tier.priceConfigured, enabled: tier.enabled,
};
}
function legacyPriceTierToForm(item: ChannelModel): PriceTierFormValues {
return {
operation: "*", quality: "*", size: "*", resolution: "*", videoSeconds: 0, providerModelKey: item.providerModelKey || "", billingMode: item.billingMode,
unitPrice: item.unitPriceMicrocredits / 1_000_000, inputTokenPrice: item.inputTokenPriceMicrocredits / 1_000_000,
outputTokenPrice: item.outputTokenPriceMicrocredits / 1_000_000, cachedTokenPrice: item.cachedTokenPriceMicrocredits / 1_000_000,
priceConfigured: item.priceConfigured, enabled: item.enabled,
};
}
function billingSummary(item: ChannelModel) {
const tiers = item.priceTiers?.filter((tier) => tier.enabled && tier.priceConfigured) || [];
if (!tiers.length) return <AdminStatusBadge label="未配置价格" tone="warning" />;
return <div className="space-y-1 text-xs leading-5">{tiers.slice(0, 3).map((tier) => <div key={tier.id}>{priceTierLabel(tier)}</div>)}{tiers.length > 3 ? <div className="text-foreground/45">另有 {tiers.length - 3} 个规格价格档</div> : null}</div>;
}
function priceTierLabel(tier: ChannelModelPriceTier) {
const selector = tier.selector || {};
const specParts = [
selector.operation && selector.operation !== "*" ? operationLabel(selector.operation) : "任意生成方式",
selector.quality && selector.quality !== "*" ? selector.quality.toUpperCase() : "",
selector.size && selector.size !== "*" ? selector.size : "",
tier.resolution === "*" ? "" : tier.resolution.toUpperCase(),
tier.videoSeconds ? `${tier.videoSeconds} 秒` : "",
].filter(Boolean);
const spec = specParts.length ? specParts.join(" / ") : "默认规格";
if (tier.billingMode === "token") return `${spec} · ${formatCredits(tier.outputTokenPriceMicrocredits)} / 百万 Token`;
return `${spec} · ${formatCredits(tier.unitPriceMicrocredits)} 积分 / ${tier.billingMode === "per_second" ? "秒" : "次"}`;
}
function operationOptions(capability: EditableCapability | undefined) {
const options = [{ label: "任意生成方式", value: "*" }];
if (capability === "image") return [...options, { label: "文生图", value: "text_to_image" }, { label: "图生图", value: "image_to_image" }];
if (capability === "video") return [...options, { label: "文生视频", value: "text_to_video" }, { label: "图生视频", value: "image_to_video" }, { label: "视频生视频", value: "video_to_video" }];
if (capability === "text") return [...options, { label: "文本生成", value: "text_generation" }];
return options;
}
function operationLabel(operation: string) {
return ({ text_to_image: "文生图", image_to_image: "图生图", text_to_video: "文生视频", image_to_video: "图生视频", video_to_video: "视频生视频", text_generation: "文本生成" } as Record<string, string>)[operation] || operation;
}
function skuSelectorFromForm(capability: EditableCapability, tier: PriceTierFormValues) {
const selector: Record<string, string> = {};
if (tier.operation && tier.operation !== "*") selector.operation = tier.operation;
if (capability === "video") {
if (tier.resolution && tier.resolution !== "*") selector.vquality = tier.resolution;
if (Number(tier.videoSeconds) > 0) selector.videoSeconds = String(Number(tier.videoSeconds));
}
if (capability === "image") {
if (tier.quality && tier.quality !== "*") selector.quality = tier.quality;
if (tier.size && tier.size !== "*") selector.size = tier.size;
}
return selector;
}
function formatCredits(value: number) {
return (value / 1_000_000).toLocaleString("zh-CN", { maximumFractionDigits: 6 });
}

View File

@@ -1,4 +1,4 @@
import { Alert, App, Button, Drawer, Form, Input, InputNumber, Modal, Segmented, Select, Switch, Table, Tag } from "antd";
import { Alert, App, Button, Drawer, Form, Input, InputNumber, Modal, Select, Switch, Table, Tag } from "antd";
import type { FormInstance } from "antd";
import type { ColumnsType } from "antd/es/table";
import { Archive, FlaskConical, GitBranch, Layers3, Pencil, Plus, Search } from "lucide-react";
@@ -7,8 +7,6 @@ import { useDeferredValue, useEffect, useMemo, useState, type ReactNode } from "
import { PaginationBar } from "@/components/layout/workspace-page";
import { ModelIconPicker, ModelLogo } from "@/components/model-logo";
import { CapabilityCardPicker } from "@/components/model-protocol-picker";
import { formatCredits } from "@/constant/credits";
import { modelProtocolSupportsTokenBilling } from "@/lib/model-protocols";
import { AdminPageFrame } from "@/pages/admin/components/admin-shell";
import { AdminDataTable, AdminFilterChip, AdminRowActions, AdminStatusBadge, AdminTableEmpty } from "@/pages/admin/components/admin-ui";
import { listAdminChannels } from "@/services/api/auth";
@@ -132,7 +130,7 @@ export default function LogicalModelsPage() {
capability,
enabled: true,
sortOrder: models.length,
pricePolicy: "unified",
pricePolicy: "channel",
billingMode: "fixed_request",
unitPriceMicrocredits: 0,
inputPriceMicrocredits: 0,
@@ -279,12 +277,7 @@ export default function LogicalModelsPage() {
return (
<AdminPageFrame
title="模型目录"
actions={
<Button type="primary" icon={<Plus className="size-4" />} onClick={() => openModel()}>
新增模型
</Button>
}
title="前台模型目录"
>
<AdminDataTable
toolbar={
@@ -363,7 +356,8 @@ export default function LogicalModelsPage() {
routes: [],
capabilitySpec: emptyCapabilitySpec(capability),
defaultOptions: {},
billingMode: capability === "text" ? "token" : capability === "video" ? "per_second" : "fixed_request",
pricePolicy: "channel",
billingMode: "fixed_request",
});
}}
>
@@ -416,8 +410,8 @@ export default function LogicalModelsPage() {
<DefaultOptionsEditor spec={modelCapabilitySpec} />
</Form.Item>
</DrawerSection>
<DrawerSection title="用户价格">
<PricingFields channelModels={channelModels} />
<DrawerSection title="系统规格价格">
<PricingFields />
</DrawerSection>
</Form>
</Drawer>
@@ -600,114 +594,24 @@ function logicalModelStatusTag(item: AdminLogicalModel) {
return <AdminStatusBadge label="可用" tone="success" />;
}
function PricingFields({ channelModels }: { channelModels: ChannelModel[] }) {
function PricingFields() {
const form = Form.useFormInstance<LogicalModelFormValues>();
const pricePolicy = Form.useWatch("pricePolicy") as LogicalModelMutation["pricePolicy"] | undefined;
const billingMode = Form.useWatch("billingMode") as LogicalModelMutation["billingMode"] | undefined;
const capability = Form.useWatch("capability") as CapabilityKind | undefined;
const routes = (Form.useWatch("routes") || []) as RouteRuleRow[];
const enabledRoutes = routes.filter((route) => route.enabled);
const tokenBillingSupported =
capability === "text" ||
(capability === "video" &&
enabledRoutes.length > 0 &&
enabledRoutes.every((route) => {
const channelModel = channelModels.find((item) => item.id === route.channelModelId);
return modelProtocolSupportsTokenBilling(channelModel?.capability, channelModel?.protocol);
}));
const modes: Array<{ label: string; value: LogicalModelMutation["billingMode"]; disabled?: boolean }> = [{ label: "按次", value: "fixed_request" }];
if (capability === "video") {
modes.push({ label: "按秒", value: "per_second" });
modes.push({ label: "Token", value: "token", disabled: !tokenBillingSupported });
}
if (capability === "text") modes.push({ label: "Token", value: "token" });
useEffect(() => {
if (pricePolicy === "unified" && billingMode === "token" && !tokenBillingSupported) {
form.setFieldValue("billingMode", capability === "video" ? "per_second" : "fixed_request");
}
}, [billingMode, capability, form, pricePolicy, tokenBillingSupported]);
const changePolicy = (value: string | number) => {
const nextPolicy = value as LogicalModelMutation["pricePolicy"];
if (nextPolicy !== "unified") return;
const supportedModes = modes.filter((item) => !item.disabled).map((item) => item.value);
if (!billingMode || !supportedModes.includes(billingMode)) {
form.setFieldValue("billingMode", capability === "text" ? "token" : capability === "video" ? "per_second" : "fixed_request");
}
};
return (
<>
<Form.Item name="pricePolicy" label="定价策略">
<Segmented
block
options={[
{ label: "跟随供应价格", value: "channel" },
{ label: "统一定价", value: "unified" },
]}
onChange={changePolicy}
/>
</Form.Item>
{pricePolicy === "unified" ? (
<>
<div className="grid gap-3 sm:grid-cols-2">
<Form.Item name="billingMode" label="计费方式">
<Segmented block options={modes} />
</Form.Item>
{billingMode !== "token" ? (
<Form.Item name="unitPriceMicrocredits" label={billingMode === "per_second" ? "每秒消耗积分" : "每次消耗积分"}>
<CreditsInput />
</Form.Item>
) : null}
</div>
{capability === "video" && !tokenBillingSupported ? <div className="mb-3 text-xs leading-5 text-foreground/50">Token 计费仅在所有启用供应线路都使用火山方舟视频协议时可用。</div> : null}
{billingMode === "token" ? (
capability === "video" ? (
<Form.Item name="outputPriceMicrocredits" label="视频 / 百万 Token" rules={[{ type: "number", min: 1, message: "请输入视频 Token 价格" }]}>
<CreditsInput />
</Form.Item>
) : (
<div className="grid gap-3 sm:grid-cols-3">
<Form.Item name="inputPriceMicrocredits" label="输入 / 百万 Token">
<CreditsInput />
</Form.Item>
<Form.Item name="outputPriceMicrocredits" label="输出 / 百万 Token">
<CreditsInput />
</Form.Item>
<Form.Item name="cachedPriceMicrocredits" label="缓存 / 百万 Token">
<CreditsInput />
</Form.Item>
</div>
)
) : null}
</>
) : (
<div className="rounded-md bg-muted/20 px-3 py-3 text-xs leading-5 text-foreground/55">用户费用按实际命中的供应线路价格计算。故障切换到更高价格线路时会重新校验余额。</div>
)}
</>
);
}
function CreditsInput({ value = 0, onChange }: { value?: number; onChange?: (value: number) => void }) {
return <InputNumber className="w-full" min={0} max={1_000_000} precision={6} step={0.1} value={value / 1_000_000} onChange={(next) => onChange?.(Math.round((next || 0) * 1_000_000))} />;
form.setFieldsValue({
pricePolicy: "channel",
billingMode: "fixed_request",
unitPriceMicrocredits: 0,
inputPriceMicrocredits: 0,
outputPriceMicrocredits: 0,
cachedPriceMicrocredits: 0,
});
}, [form]);
return <div className="rounded-md bg-muted/20 px-3 py-3 text-xs leading-5 text-foreground/55">价格、上游 SKU 和可用规格只在“系统渠道 / 模型管理”配置。前台模型只负责展示、能力范围和故障切换,不再保存第二份价格。</div>;
}
function logicalPriceLabel(item: AdminLogicalModel) {
if (item.pricePolicy === "channel") return <span className="text-xs">跟随供应价格</span>;
if (item.billingMode === "token" && item.capability === "video") return <span className="text-xs">视频 {formatCredits(item.outputPriceMicrocredits)} / 百万 Token</span>;
if (item.billingMode === "token")
return (
<div className="text-xs">
<div>输入 {formatCredits(item.inputPriceMicrocredits)} / 百万</div>
<div>输出 {formatCredits(item.outputPriceMicrocredits)} / 百万</div>
<div className="text-foreground/45">缓存 {formatCredits(item.cachedPriceMicrocredits)} / 百万</div>
</div>
);
return (
<span className="text-xs">
{formatCredits(item.unitPriceMicrocredits)} / {item.billingMode === "per_second" ? "秒" : "次"}
</span>
);
if (!item.priceTiers.length) return <span className="text-xs text-foreground/45">待配置系统规格价格</span>;
return <span className="text-xs">{item.priceTiers.length} 个系统规格档</span>;
}
function logicalModelToForm(item: AdminLogicalModel): LogicalModelFormValues {
@@ -719,12 +623,12 @@ function logicalModelToForm(item: AdminLogicalModel): LogicalModelFormValues {
capability: item.capability,
enabled: item.enabled,
sortOrder: item.sortOrder,
pricePolicy: item.pricePolicy,
billingMode: item.billingMode,
unitPriceMicrocredits: item.unitPriceMicrocredits,
inputPriceMicrocredits: item.inputPriceMicrocredits,
outputPriceMicrocredits: item.outputPriceMicrocredits,
cachedPriceMicrocredits: item.cachedPriceMicrocredits,
pricePolicy: "channel",
billingMode: "fixed_request",
unitPriceMicrocredits: 0,
inputPriceMicrocredits: 0,
outputPriceMicrocredits: 0,
cachedPriceMicrocredits: 0,
capabilitySpec: item.capabilitySpec,
defaultOptions: item.defaultOptions,
routes: item.routes.map((route) => ({ channelModelId: route.channelModelId, enabled: route.enabled, priority: route.priority, weight: route.weight })),
@@ -741,12 +645,12 @@ function logicalModelPayload(values: LogicalModelFormValues, sourceSpecs: Capabi
capability: values.capability,
enabled: values.enabled,
sortOrder: values.sortOrder || 0,
pricePolicy: values.pricePolicy,
billingMode: values.billingMode,
unitPriceMicrocredits: values.unitPriceMicrocredits || 0,
inputPriceMicrocredits: values.inputPriceMicrocredits || 0,
outputPriceMicrocredits: values.outputPriceMicrocredits || 0,
cachedPriceMicrocredits: values.cachedPriceMicrocredits || 0,
pricePolicy: "channel",
billingMode: "fixed_request",
unitPriceMicrocredits: 0,
inputPriceMicrocredits: 0,
outputPriceMicrocredits: 0,
cachedPriceMicrocredits: 0,
capabilitySpec,
defaultOptions: sanitizeDefaults(capabilitySpec, values.defaultOptions),
routes: values.routes.map((route) => ({ ...route, priority: route.priority || 0, weight: route.weight || 0 })),

View File

@@ -1152,7 +1152,7 @@ function CreationComposer(props: ComposerProps) {
<ModePicker mode={props.mode} onModeChange={props.onModeChange} />
<Tooltip title="从本机上传附件"><button type="button" className="creation-chat-control" onClick={() => props.fileInputRef.current?.click()} disabled={props.busy || !referencesSupported} aria-label="从本机上传附件"><Paperclip /><span>附件</span></button></Tooltip>
<Tooltip title={!referencesSupported ? "当前模型不支持参考媒体" : "从素材库选择参考内容"}><button type="button" className="creation-chat-control" onClick={props.onOpenLibrary} disabled={props.busy || !referencesSupported} aria-label="打开素材库选择参考内容"><FolderOpen /><span>素材库</span></button></Tooltip>
<ModelPicker config={props.config} value={props.model} onChange={props.onModelChange} capability={props.mode} requirements={props.modelRequirements} className="creation-model-picker" placeholder={`选择${modeLabels[props.mode]}模型`} showSelectedPrice={false} variant="creation" />
<ModelPicker config={props.config} value={props.model} onChange={props.onModelChange} capability={props.mode} requirements={props.modelRequirements} className="creation-model-picker" placeholder={`选择${modeLabels[props.mode]}模型`} showSelectedPrice variant="creation" />
{props.mode === "video" || (props.mode === "image" && imageSettingsSupported) ? <GenerationSettingsMenu {...props} /> : null}
{props.mode === "video" ? <DurationMenu profile={props.videoProfile} seconds={props.seconds} onChange={props.setSeconds} /> : null}
</div>

View File

@@ -31,12 +31,25 @@ export type PublicLogicalModel = {
inputPriceMicrocredits: number;
outputPriceMicrocredits: number;
cachedPriceMicrocredits: number;
priceTiers: PublicLogicalModelPriceTier[];
legacyModelIds: string[];
capabilitySpec: CapabilitySpec;
capabilityProfiles: CapabilitySpec[];
defaultOptions: Record<string, unknown>;
available: boolean;
};
export type PublicLogicalModelPriceTier = {
selector: Record<string, string>;
resolution: string;
videoSeconds: number;
billingMode: "fixed_request" | "per_second" | "token";
unitPriceMicrocredits: number;
inputTokenPriceMicrocredits: number;
outputTokenPriceMicrocredits: number;
cachedTokenPriceMicrocredits: number;
};
export type AdminLogicalRoute = {
id: string;
channelModelId: string;
@@ -73,6 +86,7 @@ export type LogicalModelMutation = {
inputPriceMicrocredits: number;
outputPriceMicrocredits: number;
cachedPriceMicrocredits: number;
legacyModelIds?: string[];
capabilitySpec: CapabilitySpec;
defaultOptions: Record<string, unknown>;
routes: Array<{ channelModelId: string; enabled: boolean; priority: number; weight: number }>;
@@ -83,6 +97,14 @@ export type RouteSimulationResult = {
candidates: Array<{ routeId: string; channelModelId: string; channelModelKey: string; channelModelName: string; priority: number; weight: number; enabled: boolean; matched: boolean; blocked: boolean; inPool: boolean; reasons?: string[] }>;
};
export type LogicalModelQuote = {
logicalModelId: string;
billingMode: PublicLogicalModel["billingMode"];
quantity: number;
amountMicrocredits: number;
estimated: boolean;
};
export function listLogicalModels() {
return request<{ models: PublicLogicalModel[] }>(apiClient.get("/models"));
}
@@ -91,6 +113,10 @@ export function listAvailableLogicalModels(intent: ModelRequestIntent) {
return request<{ models: PublicLogicalModel[] }>(apiClient.post("/models/available", intent));
}
export function quoteLogicalModel(id: string, intent: ModelRequestIntent, signal?: AbortSignal) {
return request<{ quote: LogicalModelQuote }>(apiClient.post(`/models/${encodeURIComponent(id)}/quote`, intent, { signal }));
}
export function listAdminLogicalModels() {
return request<{ models: AdminLogicalModel[] }>(apiClient.get("/admin/logical-models"));
}

View File

@@ -50,6 +50,7 @@ export type ChannelModel = {
id: string;
channelId: string;
modelKey: string;
providerModelKey: string;
displayName: string;
capability: "text" | "image" | "video" | "audio" | "";
protocol?: import("@/lib/model-protocols").ModelProtocol;
@@ -63,10 +64,49 @@ export type ChannelModel = {
priceVersion: number;
capabilityVersion?: number;
capabilityConfig?: import("@/lib/model-capabilities").ModelCapabilityConfig;
priceTiers: ChannelModelPriceTier[];
createdAt: string;
updatedAt: string;
};
export type ChannelModelPriceTier = {
id: string;
channelModelId: string;
selector: Record<string, string>;
selectorKey: string;
resolution: string;
videoSeconds: number;
providerModelKey: string;
billingMode: "fixed_request" | "per_second" | "token";
unitPriceMicrocredits: number;
inputTokenPriceMicrocredits: number;
outputTokenPriceMicrocredits: number;
cachedTokenPriceMicrocredits: number;
priceConfigured: boolean;
enabled: boolean;
priceVersion: number;
createdAt: string;
updatedAt: string;
};
// 系统渠道模型的写入合同。标量价格只用于兼容旧管理请求;新的后台界面只提交 priceTiers。
export type ChannelModelMutation = {
modelKey: string;
providerModelKey?: string;
displayName?: string;
capability: ChannelModel["capability"];
protocol?: ChannelModel["protocol"];
enabled?: boolean;
capabilityConfig?: ChannelModel["capabilityConfig"];
priceTiers?: Array<Omit<ChannelModelPriceTier, "id" | "channelModelId" | "selectorKey" | "priceVersion" | "createdAt" | "updatedAt">>;
billingMode?: ChannelModel["billingMode"];
unitPriceMicrocredits?: number;
inputTokenPriceMicrocredits?: number;
outputTokenPriceMicrocredits?: number;
cachedTokenPriceMicrocredits?: number;
priceConfigured?: boolean;
};
export type LinuxDOSetting = {
enabled: boolean;
clientId: string;
@@ -230,15 +270,15 @@ export function fetchAdminChannelModels(channelId: string) {
return request<{ models: string[]; added: number }>(api.post(`/admin/channels/${encodeURIComponent(channelId)}/models/fetch`));
}
export function testAdminChannelModel(channelId: string, input: Pick<ChannelModel, "modelKey" | "capability" | "protocol"> & { capabilityConfig?: ChannelModel["capabilityConfig"] }) {
export function testAdminChannelModel(channelId: string, input: Pick<ChannelModel, "modelKey" | "providerModelKey" | "capability" | "protocol"> & { capabilityConfig?: ChannelModel["capabilityConfig"] }) {
return request<{ durationMs: number }>(api.post(`/admin/channels/${encodeURIComponent(channelId)}/models/test`, input, { timeout: 10 * 60 * 1000 }));
}
export function createAdminChannelModel(channelId: string, input: Omit<ChannelModel, "id" | "channelId" | "priceVersion" | "createdAt" | "updatedAt">) {
export function createAdminChannelModel(channelId: string, input: ChannelModelMutation) {
return request<{ model: ChannelModel }>(api.post(`/admin/channels/${encodeURIComponent(channelId)}/models`, input));
}
export function updateAdminChannelModel(channelId: string, id: string, input: Omit<ChannelModel, "id" | "channelId" | "priceVersion" | "createdAt" | "updatedAt">) {
export function updateAdminChannelModel(channelId: string, id: string, input: ChannelModelMutation) {
return request<{ model: ChannelModel }>(api.patch(`/admin/channels/${encodeURIComponent(channelId)}/models/${encodeURIComponent(id)}`, input));
}

View File

@@ -11,7 +11,7 @@ import type { ModelCapabilityConfig } from "@/lib/model-capabilities";
import { useLocalDreaminaModelStore } from "@/stores/use-local-dreamina-model-store";
import { useUserStore } from "@/stores/use-user-store";
import type { DreaminaLocalModel } from "@/services/local-dreamina-model-catalog";
import type { CapabilitySpec } from "@/services/api/logical-models";
import type { CapabilitySpec, PublicLogicalModelPriceTier } from "@/services/api/logical-models";
export type ApiCallFormat = "openai" | "gemini";
export type ChannelInterfaceType = ModelProtocol;
@@ -31,6 +31,8 @@ export type ModelChannel = {
apiFormat: ApiCallFormat;
interfaceType?: ChannelInterfaceType;
models: string[];
// 仅平台目录使用:将已保存的旧 SKU 选择重定向到当前模型家族。
modelAliases?: Record<string, string>;
scope?: "system" | "user";
enabled?: boolean;
hasApiKey?: boolean;
@@ -53,6 +55,7 @@ export type ModelChannel = {
logicalModelId?: string;
logicalCapabilitySpec?: CapabilitySpec;
logicalCapabilityProfiles?: CapabilitySpec[];
logicalPriceTiers?: PublicLogicalModelPriceTier[];
defaultOptions?: Record<string, unknown>;
}>;
transport?: "backend-channel" | "local-runtime";
@@ -481,10 +484,12 @@ export function normalizeModelOptionValue(value: unknown, channels: ModelChannel
const decoded = decodeChannelModel(model);
if (decoded) {
const channel = channels.find((item) => item.id === decoded.channelId);
return channel && channel.models.includes(decoded.model) ? model : "";
const resolved = channel?.modelAliases?.[decoded.model] || decoded.model;
return channel && channel.models.includes(resolved) ? encodeChannelModel(channel.id, resolved) : "";
}
const channel = channels.find((item) => item.models.includes(model)) || channels[0];
return channel && channel.models.includes(model) ? encodeChannelModel(channel.id, model) : "";
const channel = channels.find((item) => item.models.includes(model) || Boolean(item.modelAliases?.[model])) || channels[0];
const resolved = channel?.modelAliases?.[model] || model;
return channel && channel.models.includes(resolved) ? encodeChannelModel(channel.id, resolved) : "";
}
export function resolveModelChannel(config: AiConfig, value: string) {

View File

@@ -4,7 +4,7 @@ import { canvasConnectionError } from "../src/lib/canvas/canvas-connection-polic
import { assertCanvasImageReferenceLimit, buildGenerationConfig, canvasImageReferenceLimitError, resolveCanvasGenerationModel } from "../src/lib/canvas/canvas-project-generation";
import { defaultModelCapabilityConfig } from "../src/lib/model-capabilities";
import { groupModelsByDisplayName, modelCompatibilityError, modelGroupReferenceLimits, resolveCompatibleModel, resolveModelGenerationDefaults } from "../src/lib/model-selection";
import { defaultConfig, type AiConfig, type ModelChannel } from "../src/stores/use-config-store";
import { defaultConfig, normalizeModelOptionValue, type AiConfig, type ModelChannel } from "../src/stores/use-config-store";
import { CanvasNodeType, type CanvasConnection, type CanvasNodeData } from "../src/types/canvas";
function policyConfig(): AiConfig {
@@ -167,6 +167,61 @@ describe("逻辑模型选择", () => {
expect(modelCompatibilityError(config, "relay::cinema-image", requirements)).toContain("参考音频");
});
test("逻辑视频模型将 720 与 720p 视为同一分辨率", () => {
const model = "cinema-720p";
const channel: ModelChannel = {
id: "logical-video",
name: "平台视频模型",
baseUrl: "/api",
apiKey: "system",
apiFormat: "openai",
scope: "system",
models: [model],
modelCosts: [{
model,
capability: "video",
billingMode: "per_second",
unitPriceMicrocredits: 1,
logicalCapabilitySpec: {
version: 1,
capability: "video",
operations: ["text_to_video"],
inputs: {},
options: {
videoSeconds: { min: 1, max: 15, step: 1 },
size: { values: ["16:9"] },
vquality: { values: ["720p"] },
videoGenerateAudio: { values: [false] },
videoWatermark: { values: [false] },
},
},
}],
};
const value = `logical-video::${model}`;
const config = { ...defaultConfig, channels: [channel], models: [value], videoModels: [value], videoModel: value };
expect(modelCompatibilityError(config, value, {
capability: "video",
input: { textCount: 1, imageCount: 0, videoCount: 0, audioCount: 0, characterCount: 0 },
options: { size: "16:9", videoSeconds: 6, vquality: "720", videoGenerateAudio: false, videoWatermark: false },
})).toBe("");
});
test("已保存的旧 SKU 选择会解析到新的模型家族", () => {
const channel: ModelChannel = {
id: "managed",
name: "平台模型",
baseUrl: "/api",
apiKey: "system",
apiFormat: "openai",
scope: "system",
models: ["family-seedance-2-5"],
modelAliases: { "legacy-seedance-720": "family-seedance-2-5" },
};
expect(normalizeModelOptionValue("managed::legacy-seedance-720", [channel])).toBe("managed::family-seedance-2-5");
});
test("逻辑模型容量使用同名细分模型的最大值", () => {
const config = policyConfig();
expect(modelGroupReferenceLimits(config, "relay::cinema-text", "video")).toEqual({ maxImages: 1, maxVideos: 0, maxAudios: 1 });