diff --git a/backend/cmd/migrate-channel-model-price-tiers/main.go b/backend/cmd/migrate-channel-model-price-tiers/main.go new file mode 100644 index 000000000..e915d33d6 --- /dev/null +++ b/backend/cmd/migrate-channel-model-price-tiers/main.go @@ -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 +} diff --git a/backend/cmd/migrate-logical-model-families/main.go b/backend/cmd/migrate-logical-model-families/main.go new file mode 100644 index 000000000..b27a4e1a2 --- /dev/null +++ b/backend/cmd/migrate-logical-model-families/main.go @@ -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) + } +} diff --git a/backend/cmd/reseed-logical-model-sources/main.go b/backend/cmd/reseed-logical-model-sources/main.go new file mode 100644 index 000000000..9071abf60 --- /dev/null +++ b/backend/cmd/reseed-logical-model-sources/main.go @@ -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 +} diff --git a/backend/internal/database/schema.go b/backend/internal/database/schema.go index c8a823be5..6e1d85031 100644 --- a/backend/internal/database/schema.go +++ b/backend/internal/database/schema.go @@ -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 { diff --git a/backend/internal/handler/logical_models.go b/backend/internal/handler/logical_models.go index 6a5c26454..420c6f899 100644 --- a/backend/internal/handler/logical_models.go +++ b/backend/internal/handler/logical_models.go @@ -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 { diff --git a/backend/internal/model/model_sku.go b/backend/internal/model/model_sku.go new file mode 100644 index 000000000..157cc97a6 --- /dev/null +++ b/backend/internal/model/model_sku.go @@ -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 +} diff --git a/backend/internal/model/models_channel.go b/backend/internal/model/models_channel.go index d8d02cb19..a86a0bf1f 100644 --- a/backend/internal/model/models_channel.go +++ b/backend/internal/model/models_channel.go @@ -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 { diff --git a/backend/internal/model/models_finance.go b/backend/internal/model/models_finance.go index 54db099c6..ec8c09949 100644 --- a/backend/internal/model/models_finance.go +++ b/backend/internal/model/models_finance.go @@ -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"` diff --git a/backend/internal/model/models_logical_model.go b/backend/internal/model/models_logical_model.go index 762f7c1c5..415cfcd4e 100644 --- a/backend/internal/model/models_logical_model.go +++ b/backend/internal/model/models_logical_model.go @@ -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"` diff --git a/backend/internal/repository/finance.go b/backend/internal/repository/finance.go index 337bff5ea..2793ff9cc 100644 --- a/backend/internal/repository/finance.go +++ b/backend/internal/repository/finance.go @@ -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 diff --git a/backend/internal/repository/logical_models.go b/backend/internal/repository/logical_models.go index 34c6fc09a..522e98458 100644 --- a/backend/internal/repository/logical_models.go +++ b/backend/internal/repository/logical_models.go @@ -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 { diff --git a/backend/internal/service/admin.go b/backend/internal/service/admin.go index 0aa86f86c..2e9504618 100644 --- a/backend/internal/service/admin.go +++ b/backend/internal/service/admin.go @@ -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}) } } diff --git a/backend/internal/service/admin_channel_test.go b/backend/internal/service/admin_channel_test.go index 0d8b6b458..ac26f277d 100644 --- a/backend/internal/service/admin_channel_test.go +++ b/backend/internal/service/admin_channel_test.go @@ -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{}) diff --git a/backend/internal/service/channel_models.go b/backend/internal/service/channel_models.go index 13e462c95..42741aedb 100644 --- a/backend/internal/service/channel_models.go +++ b/backend/internal/service/channel_models.go @@ -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 { diff --git a/backend/internal/service/channel_models_test.go b/backend/internal/service/channel_models_test.go index f50eedaf4..729c46fd7 100644 --- a/backend/internal/service/channel_models_test.go +++ b/backend/internal/service/channel_models_test.go @@ -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 { diff --git a/backend/internal/service/finance.go b/backend/internal/service/finance.go index 6607f8a9d..7aa4cb6ae 100644 --- a/backend/internal/service/finance.go +++ b/backend/internal/service/finance.go @@ -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)} diff --git a/backend/internal/service/logical_model_quote.go b/backend/internal/service/logical_model_quote.go new file mode 100644 index 000000000..0fdf64197 --- /dev/null +++ b/backend/internal/service/logical_model_quote.go @@ -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] +} diff --git a/backend/internal/service/logical_models.go b/backend/internal/service/logical_models.go index 6fd85ffd4..d96b9e5ac 100644 --- a/backend/internal/service/logical_models.go +++ b/backend/internal/service/logical_models.go @@ -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) { diff --git a/backend/internal/service/model_capability.go b/backend/internal/service/model_capability.go index 8a052a933..57405b109 100644 --- a/backend/internal/service/model_capability.go +++ b/backend/internal/service/model_capability.go @@ -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("参考素材数量超过当前模型限制") diff --git a/backend/internal/service/model_router.go b/backend/internal/service/model_router.go index e0e3db43f..2494b42e3 100644 --- a/backend/internal/service/model_router.go +++ b/backend/internal/service/model_router.go @@ -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 } diff --git a/backend/internal/service/model_router_test.go b/backend/internal/service/model_router_test.go new file mode 100644 index 000000000..66ff643ed --- /dev/null +++ b/backend/internal/service/model_router_test.go @@ -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) + } +} diff --git a/backend/internal/service/provider.go b/backend/internal/service/provider.go index b8adc5d30..e291022bc 100644 --- a/backend/internal/service/provider.go +++ b/backend/internal/service/provider.go @@ -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 { diff --git a/backend/internal/service/provider_test.go b/backend/internal/service/provider_test.go index 0100a3623..69dcf7250 100644 --- a/backend/internal/service/provider_test.go +++ b/backend/internal/service/provider_test.go @@ -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"}}, }) diff --git a/backend/internal/service/task_creation.go b/backend/internal/service/task_creation.go index a61af5061..447fae39a 100644 --- a/backend/internal/service/task_creation.go +++ b/backend/internal/service/task_creation.go @@ -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 } diff --git a/web/src/components/image-settings-panel.tsx b/web/src/components/image-settings-panel.tsx index 58aee479d..d48021bd6 100644 --- a/web/src/components/image-settings-panel.tsx +++ b/web/src/components/image-settings-panel.tsx @@ -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 ?
{isGrokResolutionQuality(profile) ? "分辨率" : "质量"}
- {activeQualityOptions.map((item) => ( - onConfigChange("quality", item.value)}> + {activeQualityOptions.map((item) => ( + onConfigChange("quality", item.value)}> {item.label} ))} @@ -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, 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 ( ); @@ -206,7 +229,7 @@ export function ModelPicker({ config, value, onChange, capability, className, fu {current ? (creationVariant ? pickerModelDisplayName(config, current, showConfiguredModelName) : pickerModelOptionLabel(config, current, showConfiguredModelName)) : placeholder} - {showSelectedPrice && creditsEnabled ? : null} + {showSelectedPrice && creditsEnabled ? : null}
@@ -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 }) { const seconds = normalizeVideoDuration(config.videoSeconds); return ( @@ -102,14 +103,14 @@ function JiMengVideoSettingsPanel({ config, profile, onConfigChange, theme, show
- onConfigChange("videoSeconds", String(value))} /> + !hasPriceTierForVideoSelection(priceTiers, "*", value)} onChange={(value) => onConfigChange("videoSeconds", String(value))} /> ); } -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 }) { 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
{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 ( onConfigChange("vquality", item.value)}> {item.label} @@ -158,7 +159,7 @@ function SeedanceVideoSettingsPanel({ config, profile, onConfigChange, theme, sh
- onConfigChange("videoSeconds", String(value))} /> + !hasPriceTierForVideoSelection(priceTiers, resolution, value)} onChange={(value) => onConfigChange("videoSeconds", String(value))} />
@@ -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 ; + return { if (!disabled?.(next)) onChange(next); }} />; } const options = videoDurationOptions(profile); return
- {options.map((option) => onChange(option)}>{option}s)} + {options.map((option) => onChange(option)}>{option}s)}
; } +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, 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
diff --git a/web/src/lib/model-selection.ts b/web/src/lib/model-selection.ts index ccebc5657..35d539cf6 100644 --- a/web/src/lib/model-selection.ts +++ b/web/src/lib/model-selection.ts @@ -145,7 +145,10 @@ function logicalModelCompatibilityError(spec: NonNullable 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) { diff --git a/web/src/lib/user-session.ts b/web/src/lib/user-session.ts index a3d6916f7..62174fc35 100644 --- a/web/src/lib/user-session.ts +++ b/web/src/lib/user-session.ts @@ -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, })), }; diff --git a/web/src/pages/admin/components/channel-model-manager.tsx b/web/src/pages/admin/components/channel-model-manager.tsx index 01de2a0bf..1e6809693 100644 --- a/web/src/pages/admin/components/channel-model-manager.tsx +++ b/web/src/pages/admin/components/channel-model-manager.tsx @@ -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 }) { @@ -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(); - 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 = [ @@ -216,6 +253,7 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel:
{item.displayName || item.modelKey}
{item.modelKey}
+ {item.providerModelKey && item.providerModelKey !== item.modelKey ?
上游:{item.providerModelKey}
: null}
), @@ -235,7 +273,7 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel: ), }, - { title: "计费", width: 220, render: (_, item) => (item.priceConfigured ? billingSummary(item) : ) }, + { title: "规格价格", width: 280, render: (_, item) => (item.priceConfigured ? billingSummary(item) : ) }, { title: "版本", dataIndex: "priceVersion", width: 75, render: (value) => `v${value}` }, { title: "状态", dataIndex: "enabled", width: 85, render: (enabled) => }, { @@ -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:

模型身份

-
- +
+ } - placeholder="例如:deepseek-chat、gpt-5、glm-4.5" + placeholder="例如:seedance-2-5" /> + + + @@ -392,48 +433,35 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel:
{modelCapability === "text" || modelCapability === "image" || modelCapability === "video" ? ( - + ) : null}
-

计费

+

规格价格档

- - - - {billingMode === "token" ? ( - modelCapability === "video" ? ( - - - - ) : ( -
- - - - - - - - - -
- ) - ) : ( - - - - )} + { if (!value?.length) throw new Error("请至少配置一个价格档"); } }]}> + {(fields, { add, remove }, { errors }) => ( +
+ {fields.map((field, index) => ( + remove(field.name)} + /> + ))} + + +
+ )} +
@@ -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; + 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 ( -
- {item.capability === "video" ? ( -
视频 {formatCredits(item.outputTokenPriceMicrocredits)} / 百万
- ) : ( - <> -
输入 {formatCredits(item.inputTokenPriceMicrocredits)} / 百万
-
输出 {formatCredits(item.outputTokenPriceMicrocredits)} / 百万
-
缓存 {formatCredits(item.cachedTokenPriceMicrocredits)} / 百万
- - )} +
+
+
价格档 {ordinal}
+
+
+ + ({ label: value.toUpperCase(), value }))]} /> + + ) : null} + {isVideo ? ( + + {durationOptions.length ? + + ) : null} + {isImage ? ( + + + + ) : null} + + + +
+
+ + + + {billingMode === "token" ? ( + isVideo ? ( + + + + ) : ( +
+ + + +
+ ) + ) : ( + + + + )} +
+ + +
+
); } +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 ; + return
{tiers.slice(0, 3).map((tier) =>
{priceTierLabel(tier)}
)}{tiers.length > 3 ?
另有 {tiers.length - 3} 个规格价格档
: null}
; +} + +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)[operation] || operation; +} + +function skuSelectorFromForm(capability: EditableCapability, tier: PriceTierFormValues) { + const selector: Record = {}; + 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 }); } diff --git a/web/src/pages/admin/logical-models/logical-models-page.tsx b/web/src/pages/admin/logical-models/logical-models-page.tsx index ee4260a5f..70a54701c 100644 --- a/web/src/pages/admin/logical-models/logical-models-page.tsx +++ b/web/src/pages/admin/logical-models/logical-models-page.tsx @@ -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 ( } onClick={() => openModel()}> - 新增模型 - - } + title="前台模型目录" > @@ -416,8 +410,8 @@ export default function LogicalModelsPage() { - - + + @@ -600,114 +594,24 @@ function logicalModelStatusTag(item: AdminLogicalModel) { return ; } -function PricingFields({ channelModels }: { channelModels: ChannelModel[] }) { +function PricingFields() { const form = Form.useFormInstance(); - 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 ( - <> - - - - {pricePolicy === "unified" ? ( - <> -
- - - - {billingMode !== "token" ? ( - - - - ) : null} -
- {capability === "video" && !tokenBillingSupported ?
Token 计费仅在所有启用供应线路都使用火山方舟视频协议时可用。
: null} - {billingMode === "token" ? ( - capability === "video" ? ( - - - - ) : ( -
- - - - - - - - - -
- ) - ) : null} - - ) : ( -
用户费用按实际命中的供应线路价格计算。故障切换到更高价格线路时会重新校验余额。
- )} - - ); -} - -function CreditsInput({ value = 0, onChange }: { value?: number; onChange?: (value: number) => void }) { - return 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
价格、上游 SKU 和可用规格只在“系统渠道 / 模型管理”配置。前台模型只负责展示、能力范围和故障切换,不再保存第二份价格。
; } function logicalPriceLabel(item: AdminLogicalModel) { - if (item.pricePolicy === "channel") return 跟随供应价格; - if (item.billingMode === "token" && item.capability === "video") return 视频 {formatCredits(item.outputPriceMicrocredits)} / 百万 Token; - if (item.billingMode === "token") - return ( -
-
输入 {formatCredits(item.inputPriceMicrocredits)} / 百万
-
输出 {formatCredits(item.outputPriceMicrocredits)} / 百万
-
缓存 {formatCredits(item.cachedPriceMicrocredits)} / 百万
-
- ); - return ( - - {formatCredits(item.unitPriceMicrocredits)} / {item.billingMode === "per_second" ? "秒" : "次"} - - ); + if (!item.priceTiers.length) return 待配置系统规格价格; + return {item.priceTiers.length} 个系统规格档; } 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 })), diff --git a/web/src/pages/create/index.tsx b/web/src/pages/create/index.tsx index aab964500..1b8225f94 100644 --- a/web/src/pages/create/index.tsx +++ b/web/src/pages/create/index.tsx @@ -1152,7 +1152,7 @@ function CreationComposer(props: ComposerProps) { - + {props.mode === "video" || (props.mode === "image" && imageSettingsSupported) ? : null} {props.mode === "video" ? : null}
diff --git a/web/src/services/api/logical-models.ts b/web/src/services/api/logical-models.ts index ece0396c3..bccb24c75 100644 --- a/web/src/services/api/logical-models.ts +++ b/web/src/services/api/logical-models.ts @@ -31,12 +31,25 @@ export type PublicLogicalModel = { inputPriceMicrocredits: number; outputPriceMicrocredits: number; cachedPriceMicrocredits: number; + priceTiers: PublicLogicalModelPriceTier[]; + legacyModelIds: string[]; capabilitySpec: CapabilitySpec; capabilityProfiles: CapabilitySpec[]; defaultOptions: Record; available: boolean; }; +export type PublicLogicalModelPriceTier = { + selector: Record; + 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; 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")); } diff --git a/web/src/services/api/wallet.ts b/web/src/services/api/wallet.ts index a771d8d5f..1973bdeef 100644 --- a/web/src/services/api/wallet.ts +++ b/web/src/services/api/wallet.ts @@ -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; + 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>; + 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 & { capabilityConfig?: ChannelModel["capabilityConfig"] }) { +export function testAdminChannelModel(channelId: string, input: Pick & { 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) { +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) { +export function updateAdminChannelModel(channelId: string, id: string, input: ChannelModelMutation) { return request<{ model: ChannelModel }>(api.patch(`/admin/channels/${encodeURIComponent(channelId)}/models/${encodeURIComponent(id)}`, input)); } diff --git a/web/src/stores/use-config-store.ts b/web/src/stores/use-config-store.ts index ba5e5254f..d1b881d39 100644 --- a/web/src/stores/use-config-store.ts +++ b/web/src/stores/use-config-store.ts @@ -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; scope?: "system" | "user"; enabled?: boolean; hasApiKey?: boolean; @@ -53,6 +55,7 @@ export type ModelChannel = { logicalModelId?: string; logicalCapabilitySpec?: CapabilitySpec; logicalCapabilityProfiles?: CapabilitySpec[]; + logicalPriceTiers?: PublicLogicalModelPriceTier[]; defaultOptions?: Record; }>; 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) { diff --git a/web/test/canvas-model-policy.test.ts b/web/test/canvas-model-policy.test.ts index 6bf208a27..2f2878ef7 100644 --- a/web/test/canvas-model-policy.test.ts +++ b/web/test/canvas-model-policy.test.ts @@ -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 });