easyai-ai-gateway/apps/api/internal/httpapi/model_catalog.go

1447 lines
42 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package httpapi
import (
"net/http"
"sort"
"strconv"
"strings"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
)
type ModelCatalogResponse struct {
Items []ModelCatalogItem `json:"items"`
Filters ModelCatalogFilters `json:"filters"`
Summary ModelCatalogSummary `json:"summary"`
}
type ModelCatalogSummary struct {
ModelCount int `json:"modelCount"`
SourceCount int `json:"sourceCount"`
}
type ModelCatalogFilters struct {
Capabilities []ModelCatalogFilterOption `json:"capabilities"`
Providers []ModelCatalogFilterOption `json:"providers"`
}
type ModelCatalogFilterOption struct {
Value string `json:"value"`
Label string `json:"label"`
Count int `json:"count"`
IconPath string `json:"iconPath,omitempty"`
}
type ModelCatalogItem struct {
ID string `json:"id"`
Alias string `json:"alias"`
DisplayName string `json:"displayName"`
ModelName string `json:"modelName"`
Description string `json:"description,omitempty"`
IconPath string `json:"iconPath,omitempty"`
ModelType []string `json:"modelType"`
CapabilityTags []string `json:"capabilityTags"`
ProviderKeys []string `json:"providerKeys"`
Providers []ModelCatalogProviderSummary `json:"providers"`
SourceCount int `json:"sourceCount"`
Source ModelCatalogText `json:"source"`
Discount ModelCatalogText `json:"discount"`
RateLimits ModelCatalogRateLimits `json:"rateLimits"`
Permission ModelCatalogPermission `json:"permission"`
Pricing ModelCatalogPricing `json:"pricing"`
Enabled bool `json:"enabled"`
StatusLabel string `json:"statusLabel,omitempty"`
Sources []ModelCatalogSource `json:"sources,omitempty"`
}
type ModelCatalogProviderSummary struct {
Key string `json:"key"`
Name string `json:"name"`
IconPath string `json:"iconPath,omitempty"`
SourceCount int `json:"sourceCount"`
}
type ModelCatalogSource struct {
ID string `json:"id"`
PlatformID string `json:"platformId,omitempty"`
PlatformName string `json:"platformName,omitempty"`
ProviderKey string `json:"providerKey"`
ProviderName string `json:"providerName"`
ModelName string `json:"modelName"`
ModelAlias string `json:"modelAlias,omitempty"`
DisplayName string `json:"displayName"`
ModelType []string `json:"modelType"`
Enabled bool `json:"enabled"`
RateLimits ModelCatalogRateLimits `json:"rateLimits"`
}
type ModelCatalogText struct {
Label string `json:"label"`
Title string `json:"title,omitempty"`
}
type ModelCatalogPermission struct {
Label string `json:"label"`
Title string `json:"title,omitempty"`
AllowGroups []string `json:"allowGroups,omitempty"`
DenyGroups []string `json:"denyGroups,omitempty"`
}
type ModelCatalogPricing struct {
Lines []string `json:"lines"`
Title string `json:"title,omitempty"`
}
type ModelCatalogRateLimits struct {
RPM *float64 `json:"rpm,omitempty"`
TPM *float64 `json:"tpm,omitempty"`
Concurrent *float64 `json:"concurrent,omitempty"`
Label string `json:"label"`
Title string `json:"title,omitempty"`
}
type catalogSource struct {
model store.PlatformModel
platform store.Platform
provider store.CatalogProvider
baseModel store.BaseModel
providerKey string
providerName string
description string
iconPath string
rateLimits ModelCatalogRateLimits
}
type catalogGroup struct {
key string
alias string
displayName string
modelName string
modelType []string
capabilities map[string]any
sources []catalogSource
enabled bool
}
func (s *Server) listModelCatalog(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
models, err := s.store.ListModels(ctx)
if err != nil {
s.logger.Error("list model catalog models failed", "error", err)
writeError(w, http.StatusInternalServerError, "list model catalog failed")
return
}
models = s.platformModelResponses(ctx, models)
platforms, err := s.store.ListPlatforms(ctx)
if err != nil {
s.logger.Error("list model catalog platforms failed", "error", err)
writeError(w, http.StatusInternalServerError, "list model catalog failed")
return
}
providers, err := s.store.ListCatalogProviders(ctx)
if err != nil {
s.logger.Error("list model catalog providers failed", "error", err)
writeError(w, http.StatusInternalServerError, "list model catalog failed")
return
}
runtimePolicies, err := s.store.ListRuntimePolicySets(ctx)
if err != nil {
s.logger.Error("list model catalog runtime policies failed", "error", err)
writeError(w, http.StatusInternalServerError, "list model catalog failed")
return
}
accessRules, err := s.store.ListAccessRules(ctx)
if err != nil {
s.logger.Error("list model catalog access rules failed", "error", err)
writeError(w, http.StatusInternalServerError, "list model catalog failed")
return
}
userGroups, err := s.store.ListUserGroups(ctx)
if err != nil {
s.logger.Error("list model catalog user groups failed", "error", err)
writeError(w, http.StatusInternalServerError, "list model catalog failed")
return
}
baseModels, err := s.store.ListBaseModels(ctx)
if err != nil {
s.logger.Error("list model catalog base models failed", "error", err)
writeError(w, http.StatusInternalServerError, "list model catalog failed")
return
}
writeJSON(w, http.StatusOK, buildModelCatalog(models, platforms, providers, runtimePolicies, accessRules, userGroups, baseModels))
}
func buildModelCatalog(
models []store.PlatformModel,
platforms []store.Platform,
providers []store.CatalogProvider,
runtimePolicies []store.RuntimePolicySet,
accessRules []store.AccessRule,
userGroups []store.UserGroup,
baseModels []store.BaseModel,
) ModelCatalogResponse {
platformMap := mapPlatforms(platforms)
providerMap := mapProviders(providers)
runtimePolicyMap := mapRuntimePolicies(runtimePolicies)
accessRuleGroups := mapAccessRuleGroups(accessRules, mapUserGroups(userGroups))
baseModelMap := mapBaseModels(baseModels)
groups := map[string]*catalogGroup{}
sourceCount := 0
for _, model := range models {
platform := platformMap[model.PlatformID]
baseModel := baseModelMap[model.BaseModelID]
providerKey := modelProviderKey(model, baseModel, platform)
provider := providerMap[providerKey]
providerName := firstNonEmpty(
provider.DisplayName,
stringValue(baseModel.Metadata["sourceProviderName"]),
baseModel.ProviderKey,
model.Provider,
platform.Provider,
platform.Name,
model.PlatformName,
providerKey,
)
description := modelDescription(model, baseModel)
iconPath := modelIconPath(model, baseModel, provider)
source := catalogSource{
model: model,
platform: platform,
provider: provider,
baseModel: baseModel,
providerKey: providerKey,
providerName: providerName,
description: description,
iconPath: iconPath,
rateLimits: effectiveModelRateLimits(model, platform, runtimePolicyMap),
}
key := catalogAliasKey(model)
current := groups[key]
if current == nil {
current = &catalogGroup{
key: key,
alias: firstNonEmpty(model.ModelAlias, model.DisplayName, model.ModelName),
displayName: firstNonEmpty(model.ModelAlias, model.DisplayName, model.ModelName),
modelName: model.ModelName,
modelType: cloneStringSlice(model.ModelType),
capabilities: cloneObject(model.Capabilities),
}
groups[key] = current
} else {
current.modelType = uniqueStrings(append(current.modelType, model.ModelType...))
current.capabilities = mergeCatalogObjects(current.capabilities, model.Capabilities)
}
sourceCount++
current.sources = append(current.sources, source)
current.enabled = current.enabled || (model.Enabled && platform.Status == "enabled")
}
items := make([]ModelCatalogItem, 0, len(groups))
for _, group := range groups {
items = append(items, modelCatalogItem(group, accessRuleGroups))
}
sort.Slice(items, func(i, j int) bool {
return strings.ToLower(items[i].DisplayName) < strings.ToLower(items[j].DisplayName)
})
return ModelCatalogResponse{
Items: items,
Filters: ModelCatalogFilters{
Capabilities: modelCatalogCapabilityFilters(items),
Providers: modelCatalogProviderFilters(items),
},
Summary: ModelCatalogSummary{
ModelCount: len(items),
SourceCount: sourceCount,
},
}
}
func modelCatalogItem(group *catalogGroup, accessRuleGroups map[string]accessRuleGroup) ModelCatalogItem {
sources := sortedCatalogSources(group.sources)
primary := sources[0]
providers := modelCatalogProviders(sources)
description := primary.description
if description == "" {
for _, source := range sources {
if source.description != "" {
description = source.description
break
}
}
}
iconPath := primary.iconPath
if iconPath == "" {
for _, source := range sources {
if source.iconPath != "" {
iconPath = source.iconPath
break
}
}
}
itemSources := make([]ModelCatalogSource, 0, len(sources))
for _, source := range sources {
itemSources = append(itemSources, ModelCatalogSource{
ID: source.model.ID,
PlatformID: source.model.PlatformID,
PlatformName: firstNonEmpty(source.platform.Name, source.model.PlatformName),
ProviderKey: source.providerKey,
ProviderName: source.providerName,
ModelName: source.model.ModelName,
ModelAlias: source.model.ModelAlias,
DisplayName: firstNonEmpty(source.model.DisplayName, source.model.ModelAlias, source.model.ModelName),
ModelType: cloneStringSlice(source.model.ModelType),
Enabled: source.model.Enabled && source.platform.Status == "enabled",
RateLimits: source.rateLimits,
})
}
return ModelCatalogItem{
ID: group.key,
Alias: group.alias,
DisplayName: group.displayName,
ModelName: group.modelName,
Description: description,
IconPath: iconPath,
ModelType: cloneStringSlice(group.modelType),
CapabilityTags: capabilityTagsForGroup(group.modelType, group.capabilities),
ProviderKeys: modelCatalogProviderKeys(providers),
Providers: providers,
SourceCount: len(sources),
Source: sourceText(sources),
Discount: discountText(sources),
RateLimits: aggregateRateLimits(sources),
Permission: permissionText(sources, accessRuleGroups),
Pricing: pricingText(sources),
Enabled: group.enabled,
StatusLabel: statusText(sources),
Sources: itemSources,
}
}
func sortedCatalogSources(sources []catalogSource) []catalogSource {
out := append([]catalogSource(nil), sources...)
sort.SliceStable(out, func(i, j int) bool {
leftEnabled := out[i].model.Enabled && out[i].platform.Status == "enabled"
rightEnabled := out[j].model.Enabled && out[j].platform.Status == "enabled"
if leftEnabled != rightEnabled {
return leftEnabled
}
leftPriority := out[i].platform.Priority
rightPriority := out[j].platform.Priority
if leftPriority == 0 {
leftPriority = 9999
}
if rightPriority == 0 {
rightPriority = 9999
}
if leftPriority != rightPriority {
return leftPriority < rightPriority
}
return out[i].model.ModelName < out[j].model.ModelName
})
return out
}
func modelCatalogProviders(sources []catalogSource) []ModelCatalogProviderSummary {
byKey := map[string]*ModelCatalogProviderSummary{}
for _, source := range sources {
key := source.providerKey
if key == "" {
key = "unknown"
}
current := byKey[key]
if current == nil {
current = &ModelCatalogProviderSummary{
Key: key,
Name: source.providerName,
IconPath: source.provider.IconPath,
}
byKey[key] = current
}
current.SourceCount++
}
items := make([]ModelCatalogProviderSummary, 0, len(byKey))
for _, provider := range byKey {
items = append(items, *provider)
}
sort.Slice(items, func(i, j int) bool {
return items[i].Name < items[j].Name
})
return items
}
func modelCatalogProviderKeys(providers []ModelCatalogProviderSummary) []string {
keys := make([]string, 0, len(providers))
for _, provider := range providers {
keys = append(keys, provider.Key)
}
return keys
}
func sourceText(sources []catalogSource) ModelCatalogText {
label := formatInt(len(sources)) + " 个源"
return ModelCatalogText{
Label: label,
Title: label,
}
}
func sourceTitles(sources []catalogSource) []string {
titles := make([]string, 0, len(sources))
for _, source := range sources {
titles = append(titles, firstNonEmpty(source.platform.Name, source.providerName, source.providerKey)+": "+source.model.ModelName)
}
return titles
}
func discountText(sources []catalogSource) ModelCatalogText {
values := make([]float64, 0, len(sources))
titles := make([]string, 0, len(sources))
for _, source := range sources {
discount := effectiveDiscount(source.model, source.platform)
value := discountLabel(discount)
values = append(values, discount)
titles = append(titles, firstNonEmpty(source.platform.Name, source.model.ModelName)+": "+discountTitle(value, discount))
}
return ModelCatalogText{
Label: discountRangeLabel(values),
Title: strings.Join(titles, ""),
}
}
func effectiveDiscount(model store.PlatformModel, platform store.Platform) float64 {
if model.DiscountFactor > 0 {
return model.DiscountFactor
}
if model.PricingMode == "inherit_discount" && platform.DefaultDiscountFactor > 0 {
return platform.DefaultDiscountFactor
}
return 1
}
func discountLabel(discount float64) string {
if absFloat(discount-1) < 0.000001 {
return "无折扣"
}
return formatPercent(discount)
}
func discountRangeLabel(values []float64) string {
discounts := uniqueDiscountValues(values)
if len(discounts) == 0 {
return "无折扣"
}
if len(discounts) == 1 {
return discountLabel(discounts[0])
}
return discountLabel(discounts[0]) + " - " + discountLabel(discounts[len(discounts)-1])
}
func uniqueDiscountValues(values []float64) []float64 {
if len(values) == 0 {
return nil
}
out := append([]float64(nil), values...)
sort.Float64s(out)
unique := out[:0]
for _, value := range out {
if len(unique) == 0 || absFloat(value-unique[len(unique)-1]) >= 0.000001 {
unique = append(unique, value)
}
}
return unique
}
func discountTitle(label string, discount float64) string {
if label == "无折扣" {
return "无折扣100%"
}
return formatPercent(discount)
}
func effectiveModelRateLimits(model store.PlatformModel, platform store.Platform, runtimePolicyMap map[string]store.RuntimePolicySet) ModelCatalogRateLimits {
overridePolicy := objectValue(model.RuntimePolicyOverride["rateLimitPolicy"])
runtimePolicy := map[string]any(nil)
if model.RuntimePolicySetID != "" {
runtimePolicy = runtimePolicyMap[model.RuntimePolicySetID].RateLimitPolicy
}
policies := []map[string]any{
overridePolicy,
model.RateLimitPolicy,
runtimePolicy,
platform.RateLimitPolicy,
}
limits := ModelCatalogRateLimits{
RPM: firstRateLimit(policies, "rpm"),
TPM: firstRateLimit(policies, "tpm_total"),
Concurrent: firstRateLimit(policies, "concurrent"),
}
limits.Label = rateLimitLabel(limits)
return limits
}
func firstRateLimit(policies []map[string]any, metric string) *float64 {
for _, policy := range policies {
if value := readRateLimit(policy, metric); value != nil {
return value
}
}
return nil
}
func readRateLimit(policy map[string]any, metric string) *float64 {
if len(policy) == 0 {
return nil
}
if rules, ok := policy["rules"].([]any); ok {
for _, item := range rules {
rule := objectValue(item)
if stringValue(rule["metric"]) != metric {
continue
}
if limit, ok := numberValue(rule["limit"]); ok {
return &limit
}
}
}
for _, key := range rateLimitKeys(metric) {
if value, ok := numberValue(policy[key]); ok {
return &value
}
}
platformLimits := objectValue(policy["platformLimits"])
for _, key := range rateLimitKeys(metric) {
if value, ok := numberValue(platformLimits[key]); ok {
return &value
}
}
return nil
}
func rateLimitKeys(metric string) []string {
switch metric {
case "rpm":
return []string{"rpm", "max_request_per_minute", "maxRequestsPerMinute"}
case "tpm_total":
return []string{"tpm_total", "tpm", "max_token_per_minute", "max_tokens_per_minute", "maxTokensPerMinute"}
case "concurrent":
return []string{"concurrent", "max_concurrent_requests", "maxConcurrentRequests"}
default:
return []string{metric}
}
}
func aggregateRateLimits(sources []catalogSource) ModelCatalogRateLimits {
var rpm, tpm, concurrent float64
var hasRPM, hasTPM, hasConcurrent bool
titles := make([]string, 0, len(sources))
for _, source := range sources {
limits := source.rateLimits
if limits.RPM != nil {
rpm += *limits.RPM
hasRPM = true
}
if limits.TPM != nil {
tpm += *limits.TPM
hasTPM = true
}
if limits.Concurrent != nil {
concurrent += *limits.Concurrent
hasConcurrent = true
}
titles = append(titles, firstNonEmpty(source.platform.Name, source.model.ModelName)+": "+rateLimitLabel(limits))
}
out := ModelCatalogRateLimits{Title: strings.Join(titles, "")}
if hasRPM {
out.RPM = &rpm
}
if hasTPM {
out.TPM = &tpm
}
if hasConcurrent {
out.Concurrent = &concurrent
}
out.Label = rateLimitLabel(out)
return out
}
func rateLimitLabel(limits ModelCatalogRateLimits) string {
return "RPM " + formatOptionalNumber(limits.RPM) + " / TPM " + formatOptionalNumber(limits.TPM) + " / 并发 " + formatOptionalNumber(limits.Concurrent)
}
func permissionText(sources []catalogSource, rules map[string]accessRuleGroup) ModelCatalogPermission {
allow := map[string]bool{}
deny := map[string]bool{}
for _, source := range sources {
collectAccessRuleGroups(rules["platform_model:"+source.model.ID], allow, deny)
if source.model.PlatformID != "" {
collectAccessRuleGroups(rules["platform:"+source.model.PlatformID], allow, deny)
}
collectPermissionConfigGroups(source.model.PermissionConfig, allow)
}
allowNames := sortedMapKeys(allow)
denyNames := sortedMapKeys(deny)
if len(allowNames) == 0 && len(denyNames) == 0 {
return ModelCatalogPermission{Label: "不限制", Title: "没有用户组访问限制"}
}
labelParts := []string{}
if len(allowNames) > 0 {
label := "用户组 " + strings.Join(limitStrings(allowNames, 4), "、")
if len(allowNames) > 4 {
label += "等"
}
labelParts = append(labelParts, label)
}
if len(denyNames) > 0 {
label := "拒绝 " + strings.Join(limitStrings(denyNames, 3), "、")
if len(denyNames) > 3 {
label += "等"
}
labelParts = append(labelParts, label)
}
titleParts := []string{}
if len(allowNames) > 0 {
titleParts = append(titleParts, "允许用户组:"+strings.Join(allowNames, "、"))
}
if len(denyNames) > 0 {
titleParts = append(titleParts, "拒绝用户组:"+strings.Join(denyNames, "、"))
}
return ModelCatalogPermission{
Label: strings.Join(labelParts, ""),
Title: strings.Join(titleParts, ""),
AllowGroups: allowNames,
DenyGroups: denyNames,
}
}
type accessRuleGroup struct {
allow map[string]bool
deny map[string]bool
}
func mapAccessRuleGroups(rules []store.AccessRule, userGroupMap map[string]string) map[string]accessRuleGroup {
groups := map[string]accessRuleGroup{}
for _, rule := range rules {
if rule.Status != "active" || rule.SubjectType != "user_group" || (rule.ResourceType != "platform" && rule.ResourceType != "platform_model") {
continue
}
key := rule.ResourceType + ":" + rule.ResourceID
current := groups[key]
if current.allow == nil {
current.allow = map[string]bool{}
current.deny = map[string]bool{}
}
groupName := firstNonEmpty(userGroupMap[rule.SubjectID], rule.SubjectID)
if rule.Effect == "allow" {
current.allow[groupName] = true
}
if rule.Effect == "deny" {
current.deny[groupName] = true
}
groups[key] = current
}
return groups
}
func collectAccessRuleGroups(group accessRuleGroup, allow map[string]bool, deny map[string]bool) {
for name := range group.allow {
allow[name] = true
}
for name := range group.deny {
deny[name] = true
}
}
func collectPermissionConfigGroups(config map[string]any, allow map[string]bool) {
if len(config) == 0 {
return
}
for _, key := range []string{"userGroupNames", "userGroups", "allowedUserGroups", "allowedUserGroupNames"} {
for _, value := range stringSliceValue(config[key]) {
allow[value] = true
}
}
}
func pricingText(sources []catalogSource) ModelCatalogPricing {
lineSets := make([]string, 0, len(sources))
linesBySource := make([][]string, 0, len(sources))
for _, source := range sources {
lines := billingConfigLines(source.model.BillingConfig)
if len(lines) == 0 {
lines = []string{pricingModeLabel(source.model.PricingMode)}
}
linesBySource = append(linesBySource, lines)
lineSets = append(lineSets, strings.Join(lines, "\n"))
}
uniqueSets := uniqueStrings(lineSets)
lines := []string{}
if len(uniqueSets) <= 1 {
if len(linesBySource) > 0 {
lines = append(lines, linesBySource[0]...)
}
} else {
for index, source := range sources {
label := firstNonEmpty(source.platform.Name, source.model.ModelName)
for _, line := range linesBySource[index] {
lines = append(lines, label+": "+line)
}
}
}
if len(lines) == 0 {
lines = []string{"未配置具体价格"}
}
return ModelCatalogPricing{Lines: lines, Title: strings.Join(lines, "")}
}
func billingConfigLines(config map[string]any) []string {
if len(config) == 0 {
return nil
}
if textPricingResource(config) {
if lines := textPricingLines(config); len(lines) > 0 {
return lines
}
}
if basePrice, ok := numberValue(config["basePrice"]); ok {
resourceType := firstNonEmpty(stringValue(config["resourceType"]), "model")
return []string{priceLine(resourceType, map[string]any{"basePrice": basePrice, "dynamicWeight": config["dynamicWeight"], "unit": config["unit"]})}
}
lines := textPricingLines(config)
for _, resource := range []string{"image", "image_edit", "video", "audio", "music", "digital_human", "model"} {
if nested := objectValue(config[resource]); len(nested) > 0 {
if _, ok := numberValue(nested["basePrice"]); ok {
lines = append(lines, priceLine(resource, nested))
continue
}
}
for _, baseKey := range resourceBaseKeys(resource) {
if value, ok := numberValue(config[baseKey]); ok {
lines = append(lines, resourceBaseLine(resource, value))
break
}
}
}
return uniqueStrings(lines)
}
func textPricingResource(config map[string]any) bool {
switch strings.ToLower(stringValue(config["resourceType"])) {
case "text", "text_total", "text_input", "text_output":
return true
default:
return false
}
}
func textPricingLines(config map[string]any) []string {
if len(config) == 0 {
return nil
}
inputPrice, hasInput := textPriceFromConfig(config, []string{"textInputPer1k", "textInput", "text_input", "inputTokenPrice", "inputPrice"})
outputPrice, hasOutput := textPriceFromConfig(config, []string{"textOutputPer1k", "textOutput", "text_output", "outputTokenPrice", "outputPrice"})
for _, key := range []string{"formulaConfig", "formula_config"} {
formulaConfig := objectValue(config[key])
if !hasInput {
inputPrice, hasInput = textPriceFromConfig(formulaConfig, []string{"inputTokenPrice", "textInputPer1k", "textInput", "text_input", "inputPrice"})
}
if !hasOutput {
outputPrice, hasOutput = textPriceFromConfig(formulaConfig, []string{"outputTokenPrice", "textOutputPer1k", "textOutput", "text_output", "outputPrice"})
}
}
for _, resource := range []string{"text", "text_total"} {
nested := objectValue(config[resource])
if len(nested) == 0 {
continue
}
if !hasInput {
inputPrice, hasInput = textPriceFromConfig(nested, []string{"inputTokenPrice", "textInputPer1k", "textInput", "text_input", "inputPrice"})
}
if !hasOutput {
outputPrice, hasOutput = textPriceFromConfig(nested, []string{"outputTokenPrice", "textOutputPer1k", "textOutput", "text_output", "outputPrice"})
}
for _, key := range []string{"formulaConfig", "formula_config"} {
formulaConfig := objectValue(nested[key])
if !hasInput {
inputPrice, hasInput = textPriceFromConfig(formulaConfig, []string{"inputTokenPrice", "textInputPer1k", "textInput", "text_input", "inputPrice"})
}
if !hasOutput {
outputPrice, hasOutput = textPriceFromConfig(formulaConfig, []string{"outputTokenPrice", "textOutputPer1k", "textOutput", "text_output", "outputPrice"})
}
}
if resource == "text" && !hasOutput {
outputPrice, hasOutput = textPriceFromConfig(nested, []string{"basePrice"})
}
if !hasInput {
inputPrice, hasInput = textPriceFromConfig(nested, []string{"basePrice"})
}
}
if basePrice, ok := numberValue(config["basePrice"]); ok {
switch strings.ToLower(stringValue(config["resourceType"])) {
case "text":
if !hasInput {
inputPrice, hasInput = basePrice, true
}
if !hasOutput {
outputPrice, hasOutput = basePrice, true
}
case "text_total", "text_input":
if !hasInput {
inputPrice, hasInput = basePrice, true
}
case "text_output":
if !hasOutput {
outputPrice, hasOutput = basePrice, true
}
}
}
lines := []string{}
if hasInput {
lines = append(lines, "输入 "+formatNumber(inputPrice)+"/k tokens")
}
if hasOutput {
lines = append(lines, "输出 "+formatNumber(outputPrice)+"/k tokens")
}
return uniqueStrings(lines)
}
func textPriceFromConfig(config map[string]any, keys []string) (float64, bool) {
if len(config) == 0 {
return 0, false
}
for _, key := range keys {
if value, ok := numberValue(config[key]); ok {
return value, true
}
}
return 0, false
}
func resourceBaseKeys(resource string) []string {
switch resource {
case "image":
return []string{"imageBase"}
case "image_edit":
return []string{"editBase", "imageEditBase"}
case "video":
return []string{"videoBase"}
case "audio":
return []string{"audioBase"}
case "music":
return []string{"musicBase"}
case "digital_human":
return []string{"digitalHumanBase", "digital_humanBase"}
case "model":
return []string{"modelBase"}
case "text":
return []string{"textBase"}
default:
return []string{resource + "Base"}
}
}
func priceLine(resourceType string, config map[string]any) string {
basePrice, _ := numberValue(config["basePrice"])
weights := resolutionWeightEntries(config["dynamicWeight"])
if len(weights) > 0 {
details := make([]string, 0, len(weights))
for _, item := range weights {
details = append(details, item.key+" "+formatNumber(basePrice*item.weight))
}
line := resourceTypeLabel(resourceType) + "" + strings.Join(details, " / ")
if resourceType == "video" {
line += "5秒基准"
}
return line
}
unit := firstNonEmpty(stringValue(config["unit"]), resourceType)
if resourceType == "video" && (unit == "video" || unit == "5s") {
return resourceTypeLabel(resourceType) + "" + formatNumber(basePrice) + " / 5秒基准"
}
return resourceTypeLabel(resourceType) + "" + formatNumber(basePrice) + " / " + unitLabel(unit)
}
func resourceBaseLine(resourceType string, basePrice float64) string {
if resourceType == "video" {
return resourceTypeLabel(resourceType) + "" + formatNumber(basePrice) + " / 5秒基准"
}
return resourceTypeLabel(resourceType) + "" + formatNumber(basePrice)
}
type resolutionWeight struct {
key string
weight float64
}
func resolutionWeightEntries(value any) []resolutionWeight {
record := objectValue(value)
entries := make([]resolutionWeight, 0, len(record))
for key, raw := range record {
if !isResolutionKey(key) {
continue
}
weight, ok := numberValue(raw)
if !ok {
continue
}
entries = append(entries, resolutionWeight{key: key, weight: weight})
}
sort.Slice(entries, func(i, j int) bool {
return resolutionSortValue(entries[i].key) < resolutionSortValue(entries[j].key)
})
return entries
}
func isResolutionKey(value string) bool {
normalized := strings.ToLower(strings.TrimSpace(value))
if strings.HasSuffix(normalized, "k") {
_, ok := numberValue(strings.TrimSuffix(normalized, "k"))
return ok
}
if strings.HasSuffix(normalized, "p") {
_, ok := numberValue(strings.TrimSuffix(normalized, "p"))
return ok
}
return false
}
func resolutionSortValue(value string) float64 {
normalized := strings.ToLower(strings.TrimSpace(value))
if strings.HasSuffix(normalized, "k") {
number, _ := numberValue(strings.TrimSuffix(normalized, "k"))
return number * 1000
}
if strings.HasSuffix(normalized, "p") {
number, _ := numberValue(strings.TrimSuffix(normalized, "p"))
return number
}
return 0
}
func statusText(sources []catalogSource) string {
statuses := uniqueStringsFromSources(sources, func(source catalogSource) string {
if source.platform.Status != "" {
return source.platform.Status
}
if source.model.Enabled {
return "enabled"
}
return "disabled"
})
priorities := uniqueStringsFromSources(sources, func(source catalogSource) string {
if source.platform.Priority == 0 {
return ""
}
return "优先级 " + formatInt(source.platform.Priority)
})
parts := append(statuses, priorities...)
if len(parts) == 0 {
return "平台模型"
}
return strings.Join(parts, " · ")
}
func modelCatalogCapabilityFilters(items []ModelCatalogItem) []ModelCatalogFilterOption {
counts := map[string]int{}
for _, item := range items {
for _, value := range capabilityFilterValuesForItem(item) {
counts[value]++
}
}
options := []ModelCatalogFilterOption{{Value: "all", Label: "全部", Count: len(items)}}
for _, option := range []ModelCatalogFilterOption{
{Value: "chat", Label: "对话"},
{Value: "image", Label: "图像"},
{Value: "video", Label: "视频"},
{Value: "audio", Label: "音频"},
{Value: "embedding", Label: "Embedding"},
{Value: "tools", Label: "工具调用"},
{Value: "omni", Label: "全模态"},
} {
if count := counts[option.Value]; count > 0 {
option.Count = count
options = append(options, option)
}
}
return options
}
func capabilityFilterValuesForItem(item ModelCatalogItem) []string {
values := capabilityFilterValues(item.ModelType, nil)
for _, tag := range item.CapabilityTags {
switch tag {
case "工具调用":
values = append(values, "tools")
case "全模态", "多模态":
values = append(values, "omni")
}
}
return uniqueStrings(values)
}
func modelCatalogProviderFilters(items []ModelCatalogItem) []ModelCatalogFilterOption {
counts := map[string]int{}
labels := map[string]string{}
icons := map[string]string{}
for _, item := range items {
for _, provider := range item.Providers {
counts[provider.Key]++
labels[provider.Key] = provider.Name
if provider.IconPath != "" {
icons[provider.Key] = provider.IconPath
}
}
}
options := []ModelCatalogFilterOption{{Value: "all", Label: "全部", Count: len(items)}}
keys := sortedIntMapKeys(counts)
sort.Slice(keys, func(i, j int) bool {
return labels[keys[i]] < labels[keys[j]]
})
for _, key := range keys {
options = append(options, ModelCatalogFilterOption{
Value: key,
Label: firstNonEmpty(labels[key], key),
Count: counts[key],
IconPath: icons[key],
})
}
return options
}
func capabilityTagsForGroup(modelTypes []string, capabilities map[string]any) []string {
tags := make([]string, 0, len(modelTypes)+4)
for _, modelType := range modelTypes {
tags = append(tags, capabilityLabel(modelType))
}
if boolAnyValue(capabilities["multimodal"]) || boolAnyValue(capabilities["vision"]) {
tags = append(tags, "多模态")
}
if boolAnyValue(capabilities["reasoning"]) {
tags = append(tags, "推理")
}
if boolAnyValue(capabilities["structured_output"]) || boolAnyValue(capabilities["structuredOutput"]) {
tags = append(tags, "结构化输出")
}
if boolAnyValue(capabilities["tools_call"]) || boolAnyValue(capabilities["toolCall"]) || boolAnyValue(capabilities["function_call"]) {
tags = append(tags, "工具调用")
}
return uniqueStrings(tags)
}
func capabilityFilterValues(modelTypes []string, capabilities map[string]any) []string {
values := []string{}
for _, modelType := range modelTypes {
normalized := strings.ToLower(strings.TrimSpace(modelType))
switch {
case normalized == "text_embedding" || normalized == "embedding":
values = append(values, "embedding")
case normalized == "tools_call":
values = append(values, "tools")
case normalized == "omni":
values = append(values, "omni")
case strings.Contains(normalized, "video"):
values = append(values, "video")
case strings.Contains(normalized, "audio") || strings.Contains(normalized, "speech"):
values = append(values, "audio")
case strings.Contains(normalized, "image"):
values = append(values, "image")
case normalized == "text_generate" || normalized == "chat" || normalized == "responses" || strings.Contains(normalized, "text"):
values = append(values, "chat")
}
}
if boolAnyValue(capabilities["tools_call"]) || boolAnyValue(capabilities["toolCall"]) || boolAnyValue(capabilities["function_call"]) {
values = append(values, "tools")
}
if boolAnyValue(capabilities["multimodal"]) {
values = append(values, "omni")
}
return uniqueStrings(values)
}
func capabilityLabel(value string) string {
labels := map[string]string{
"text_generate": "文本生成",
"chat": "对话",
"responses": "Responses",
"text_embedding": "Embedding",
"embedding": "Embedding",
"image_generate": "图像生成",
"image_edit": "图像编辑",
"image_analysis": "图像分析",
"video_generate": "视频生成",
"image_to_video": "图生视频",
"text_to_video": "文生视频",
"video_edit": "视频编辑",
"video_understanding": "视频理解",
"audio_generate": "音频生成",
"text_to_speech": "语音合成",
"audio_understanding": "音频理解",
"tools_call": "工具调用",
"omni": "全模态",
"omni_video": "全模态视频",
"structured_output": "结构化输出",
"digital_human": "数字人",
"model_3d": "3D 模型",
}
if label, ok := labels[value]; ok {
return label
}
return value
}
func modelDescription(model store.PlatformModel, baseModel store.BaseModel) string {
rawModel := objectValue(baseModel.Metadata["rawModel"])
capabilityRawModel := objectValue(model.Capabilities["rawModel"])
return firstNonEmpty(
stringValue(baseModel.Metadata["description"]),
stringValue(baseModel.Metadata["modelDescription"]),
stringValue(rawModel["description"]),
stringValue(model.Capabilities["description"]),
stringValue(model.Capabilities["modelDescription"]),
stringValue(capabilityRawModel["description"]),
)
}
func modelIconPath(model store.PlatformModel, baseModel store.BaseModel, provider store.CatalogProvider) string {
rawModel := objectValue(baseModel.Metadata["rawModel"])
return firstNonEmpty(
stringValue(baseModel.Metadata["iconPath"]),
stringValue(rawModel["icon_path"]),
stringValue(rawModel["iconPath"]),
stringValue(model.Capabilities["iconPath"]),
stringValue(model.Capabilities["icon_path"]),
stringValue(model.BillingConfig["iconPath"]),
stringValue(model.BillingConfig["icon_path"]),
provider.IconPath,
)
}
func catalogAliasKey(model store.PlatformModel) string {
key := strings.ToLower(strings.TrimSpace(firstNonEmpty(model.ModelAlias, model.DisplayName, model.ModelName)))
if key == "" {
return model.ID
}
return key
}
func modelProviderKey(model store.PlatformModel, baseModel store.BaseModel, platform store.Platform) string {
return normalizeCatalogProviderKey(firstNonEmpty(
baseModel.ProviderKey,
stringValue(baseModel.Metadata["sourceProviderCode"]),
stringValue(baseModel.Metadata["providerKey"]),
model.Provider,
platform.Provider,
platform.Name,
model.PlatformName,
))
}
func normalizeCatalogProviderKey(value string) string {
normalized := strings.ToLower(strings.TrimSpace(value))
if normalized == "" {
return "unknown"
}
normalized = strings.ReplaceAll(normalized, "_", "-")
normalized = strings.Join(strings.Fields(normalized), "-")
switch normalized {
case "google-gemini":
return "gemini"
case "open-ai":
return "openai"
}
return normalized
}
func mapPlatforms(platforms []store.Platform) map[string]store.Platform {
out := map[string]store.Platform{}
for _, platform := range platforms {
out[platform.ID] = platform
}
return out
}
func mapProviders(providers []store.CatalogProvider) map[string]store.CatalogProvider {
out := map[string]store.CatalogProvider{}
for _, provider := range providers {
keys := []string{
provider.ProviderKey,
provider.Code,
provider.DisplayName,
stringValue(provider.Metadata["sourceCode"]),
}
for _, key := range keys {
normalized := normalizeCatalogProviderKey(key)
if normalized != "unknown" {
out[normalized] = provider
}
}
}
return out
}
func mapRuntimePolicies(policies []store.RuntimePolicySet) map[string]store.RuntimePolicySet {
out := map[string]store.RuntimePolicySet{}
for _, policy := range policies {
out[policy.ID] = policy
}
return out
}
func mapUserGroups(groups []store.UserGroup) map[string]string {
out := map[string]string{}
for _, group := range groups {
out[group.ID] = firstNonEmpty(group.Name, group.GroupKey, group.ID)
}
return out
}
func mapBaseModels(models []store.BaseModel) map[string]store.BaseModel {
out := map[string]store.BaseModel{}
for _, model := range models {
out[model.ID] = model
}
return out
}
func mergeCatalogObjects(left map[string]any, right map[string]any) map[string]any {
if len(left) == 0 && len(right) == 0 {
return nil
}
out := cloneObject(left)
for key, value := range right {
out[key] = value
}
return out
}
func cloneObject(value map[string]any) map[string]any {
if len(value) == 0 {
return nil
}
out := make(map[string]any, len(value))
for key, item := range value {
out[key] = item
}
return out
}
func cloneStringSlice(values []string) []string {
if len(values) == 0 {
return nil
}
return append([]string(nil), values...)
}
func uniqueStrings(values []string) []string {
out := make([]string, 0, len(values))
seen := map[string]bool{}
for _, value := range values {
trimmed := strings.TrimSpace(value)
if trimmed == "" || seen[trimmed] {
continue
}
seen[trimmed] = true
out = append(out, trimmed)
}
return out
}
func uniqueStringsFromSources(sources []catalogSource, selector func(catalogSource) string) []string {
values := make([]string, 0, len(sources))
for _, source := range sources {
values = append(values, selector(source))
}
return uniqueStrings(values)
}
func limitStrings(values []string, limit int) []string {
if len(values) <= limit {
return values
}
return values[:limit]
}
func sortedMapKeys(values map[string]bool) []string {
keys := make([]string, 0, len(values))
for key := range values {
keys = append(keys, key)
}
sort.Strings(keys)
return keys
}
func objectValue(value any) map[string]any {
switch typed := value.(type) {
case map[string]any:
return typed
default:
return nil
}
}
func stringValue(value any) string {
if value == nil {
return ""
}
if text, ok := value.(string); ok {
return strings.TrimSpace(text)
}
return ""
}
func stringSliceValue(value any) []string {
switch typed := value.(type) {
case []string:
return uniqueStrings(typed)
case []any:
out := make([]string, 0, len(typed))
for _, item := range typed {
if text := stringValue(item); text != "" {
out = append(out, text)
}
}
return uniqueStrings(out)
default:
return nil
}
}
func sortedIntMapKeys(values map[string]int) []string {
keys := make([]string, 0, len(values))
for key := range values {
keys = append(keys, key)
}
sort.Strings(keys)
return keys
}
func boolAnyValue(value any) bool {
typed, ok := value.(bool)
return ok && typed
}
func numberValue(value any) (float64, bool) {
switch typed := value.(type) {
case float64:
return typed, true
case float32:
return float64(typed), true
case int:
return float64(typed), true
case int64:
return float64(typed), true
case int32:
return float64(typed), true
case string:
trimmed := strings.TrimSpace(typed)
if trimmed == "" {
return 0, false
}
if number, err := strconv.ParseFloat(trimmed, 64); err == nil {
return number, true
}
return 0, false
default:
return 0, false
}
}
func formatOptionalNumber(value *float64) string {
if value == nil {
return "-"
}
return formatLimitNumber(*value)
}
func formatLimitNumber(value float64) string {
switch {
case absFloat(value) >= 10000:
return formatNumber(value/10000) + "万"
case absFloat(value) >= 1000:
return formatNumber(value/1000) + "k"
default:
return formatNumber(value)
}
}
func formatPercent(value float64) string {
return formatNumber(value*100) + "%"
}
func formatNumber(value float64) string {
return strconv.FormatFloat(value, 'f', -1, 64)
}
func formatInt(value int) string {
return strconv.Itoa(value)
}
func absFloat(value float64) float64 {
if value < 0 {
return -value
}
return value
}
func pricingModeLabel(mode string) string {
switch mode {
case "inherit":
return "跟随基准定价"
case "inherit_discount":
return "继承并折扣"
case "custom":
return "自定义定价"
default:
if strings.TrimSpace(mode) != "" {
return mode
}
return "未配置定价"
}
}
func resourceTypeLabel(value string) string {
labels := map[string]string{
"text": "文本",
"text_total": "文本",
"text_input": "文本输入",
"text_output": "文本输出",
"image": "图像",
"image_edit": "图像编辑",
"video": "视频",
"audio": "音频",
"music": "音乐",
"digital_human": "数字人",
"model": "模型",
}
if label, ok := labels[value]; ok {
return label
}
return value
}
func unitLabel(value string) string {
labels := map[string]string{
"image": "张",
"5s": "5秒",
"second": "秒",
"item": "次",
"unit": "单位",
"1k_tokens": "k tokens",
"character_1k": "千字符",
}
if label, ok := labels[value]; ok {
return label
}
return value
}