Files
easyai-ai-gateway/apps/api/internal/httpapi/model_catalog_test.go
T
wangbo 8d86c4b3b3 feat(catalog): 统一模型调用身份并梳理生命周期
新增官方调用名、供应商真实名、显示名和兼容别名的独立契约,调整路由、目录聚合、管理端与 OpenAPI。

增加 Gemini、Qwen、DeepSeek、Claude 和 MiniMax 生命周期迁移、别名观测及引用保护,并补充数据库与聚合测试。
2026-07-22 20:23:36 +08:00

421 lines
18 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 (
"strings"
"testing"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
)
func TestBuildModelCatalogAggregatesSources(t *testing.T) {
models := []store.PlatformModel{
{
ID: "model-a",
PlatformID: "platform-a",
ModelName: "seedance",
ModelAlias: "Seedance-2.0",
ModelType: store.StringList{"image_generate"},
DisplayName: "Seedance Source A",
BillingConfig: map[string]any{
"image": map[string]any{"basePrice": float64(10), "dynamicWeight": map[string]any{"1K": float64(1), "2K": float64(2)}},
},
RateLimitPolicy: map[string]any{
"platformLimits": map[string]any{
"max_request_per_minute": 60,
"max_token_per_minute": 1000,
"max_concurrent_requests": 2,
},
},
PricingMode: "inherit_discount",
Enabled: true,
},
{
ID: "model-b",
PlatformID: "platform-b",
ModelName: "seedance",
ModelAlias: "Seedance-2.0",
ModelType: store.StringList{"image_generate"},
DisplayName: "Seedance Source B",
BillingConfig: map[string]any{
"image": map[string]any{"basePrice": float64(10), "dynamicWeight": map[string]any{"1K": float64(1), "2K": float64(2)}},
},
RateLimitPolicy: map[string]any{
"rpm": 40,
"tpm": 2000,
"concurrent": 3,
},
DiscountFactor: 0.8,
PricingMode: "custom",
Enabled: true,
},
}
platforms := []store.Platform{
{ID: "platform-a", Provider: "volces", Name: "火山引擎", Status: "enabled", Priority: 20, DefaultDiscountFactor: 1},
{ID: "platform-b", Provider: "gemini", Name: "Gemini", Status: "enabled", Priority: 10, DefaultDiscountFactor: 1},
}
providers := []store.CatalogProvider{
{ProviderKey: "volces", DisplayName: "火山引擎", IconPath: "volces.png"},
{ProviderKey: "gemini", DisplayName: "Google Gemini", IconPath: "gemini.png"},
}
accessRules := []store.AccessRule{
{SubjectType: "user_group", SubjectID: "group-vip", ResourceType: "platform", ResourceID: "platform-b", Effect: "allow", Status: "active"},
{SubjectType: "user_group", SubjectID: "group-blocked", ResourceType: "platform", ResourceID: "platform-a", Effect: "deny", Status: "active"},
}
userGroups := []store.UserGroup{
{ID: "group-vip", GroupKey: "vip", Name: "VIP 用户组"},
{ID: "group-blocked", GroupKey: "blocked", Name: "Blocked 用户组"},
}
baseModels := []store.BaseModel{
{ID: "", Metadata: map[string]any{"description": "高质量图像生成模型"}},
}
response := buildModelCatalog(models, platforms, providers, nil, accessRules, userGroups, baseModels)
if response.Summary.ModelCount != 1 || response.Summary.SourceCount != 2 {
t.Fatalf("unexpected summary: %+v", response.Summary)
}
item := response.Items[0]
if item.SourceCount != 2 {
t.Fatalf("expected merged source count, got %d", item.SourceCount)
}
if item.Source.Label != "2 个源" {
t.Fatalf("expected source label to only show count, got %q", item.Source.Label)
}
if item.RateLimits.RPM == nil || *item.RateLimits.RPM != 100 {
t.Fatalf("expected summed rpm 100, got %+v", item.RateLimits.RPM)
}
if item.RateLimits.TPM == nil || *item.RateLimits.TPM != 3000 {
t.Fatalf("expected summed tpm 3000, got %+v", item.RateLimits.TPM)
}
if item.RateLimits.Concurrent == nil || *item.RateLimits.Concurrent != 5 {
t.Fatalf("expected summed concurrency 5, got %+v", item.RateLimits.Concurrent)
}
if item.Permission.Label != "用户组 VIP 用户组;拒绝 Blocked 用户组" {
t.Fatalf("expected permission label from access rules, got %q", item.Permission.Label)
}
if len(item.Permission.AllowGroups) != 1 || item.Permission.AllowGroups[0] != "VIP 用户组" {
t.Fatalf("expected allow permission groups, got %+v", item.Permission.AllowGroups)
}
if len(item.Permission.DenyGroups) != 1 || item.Permission.DenyGroups[0] != "Blocked 用户组" {
t.Fatalf("expected deny permission groups, got %+v", item.Permission.DenyGroups)
}
if item.Discount.Label != "80% - 无折扣" {
t.Fatalf("expected friendly discount label, got %q", item.Discount.Label)
}
if len(item.ProviderKeys) != 2 {
t.Fatalf("expected both providers on merged item, got %+v", item.ProviderKeys)
}
if !hasFilterCount(response.Filters.Providers, "volces", 1) || !hasFilterCount(response.Filters.Providers, "gemini", 1) {
t.Fatalf("expected provider filters to count merged model for each provider: %+v", response.Filters.Providers)
}
if !hasFilterCount(response.Filters.Capabilities, "image_generate", 1) {
t.Fatalf("expected image generation capability filter: %+v", response.Filters.Capabilities)
}
if got := item.Pricing.Lines[0]; got != "图像:1K 10 / 2K 20" {
t.Fatalf("unexpected pricing line %q", got)
}
}
func TestBuildModelCatalogKeepsDisplayNameSeparateFromCallAlias(t *testing.T) {
models := []store.PlatformModel{
{
ID: "seedream-pro",
PlatformID: "platform-volces",
ModelName: "Seedream-5.0-Pro",
ProviderModelName: "doubao-seedream-5-0-pro-260628",
ModelAlias: "Seedream-5.0-Pro",
ModelType: store.StringList{"image_edit", "image_generate"},
DisplayName: "Seedream 5.0 Pro",
Enabled: true,
},
}
platforms := []store.Platform{
{ID: "platform-volces", Provider: "volces", Name: "火山引擎", Status: "enabled"},
}
baseModels := []store.BaseModel{
{
ID: "base-seedream-pro",
CanonicalModelKey: "seedream:seedream-5.0-pro",
InvocationName: "seedream-5.0-pro",
ProviderModelName: "doubao-seedream-5-0-pro-260628",
DisplayName: "Seedream 5.0 Pro",
Status: "active",
},
}
models[0].BaseModelID = "base-seedream-pro"
response := buildModelCatalog(models, platforms, nil, nil, nil, nil, baseModels)
if len(response.Items) != 1 {
t.Fatalf("expected one Seedream Pro catalog item, got %+v", response.Items)
}
item := response.Items[0]
if item.Alias != "seedream-5.0-pro" || item.DisplayName != "Seedream 5.0 Pro" || item.ModelName != "seedream-5.0-pro" {
t.Fatalf("catalog should separate the call alias from the display name, got %+v", item)
}
if strings.Contains(item.Alias, " ") {
t.Fatalf("Seedream Pro call alias must not contain spaces, got %q", item.Alias)
}
}
func TestBuildModelCatalogAggregatesByInvocationNameNotDisplayName(t *testing.T) {
models := []store.PlatformModel{
{ID: "official-a", PlatformID: "platform-a", BaseModelID: "base-a", ModelName: "provider/a", ModelType: store.StringList{"text_generate"}, DisplayName: "同名营销标题", Enabled: true},
{ID: "official-b", PlatformID: "platform-b", BaseModelID: "base-b", ModelName: "provider/b", ModelType: store.StringList{"text_generate"}, DisplayName: "同名营销标题", Enabled: true},
{ID: "official-a-mirror", PlatformID: "platform-b", BaseModelID: "base-a-mirror", ModelName: "mirror/a", ModelType: store.StringList{"text_generate"}, DisplayName: "另一个展示标题", Enabled: true},
}
platforms := []store.Platform{
{ID: "platform-a", Provider: "provider-a", Status: "enabled"},
{ID: "platform-b", Provider: "provider-b", Status: "enabled"},
}
baseModels := []store.BaseModel{
{ID: "base-a", CanonicalModelKey: "provider-a:official-a", InvocationName: "official-a", DisplayName: "模型 A", Status: "active"},
{ID: "base-a-mirror", CanonicalModelKey: "provider-b:official-a", InvocationName: "official-a", DisplayName: "模型 A 镜像", Status: "active"},
{ID: "base-b", CanonicalModelKey: "provider-b:official-b", InvocationName: "official-b", DisplayName: "同名营销标题", Status: "active"},
}
response := buildModelCatalog(models, platforms, nil, nil, nil, nil, baseModels)
if response.Summary.ModelCount != 2 || response.Summary.SourceCount != 3 {
t.Fatalf("expected two official identities and three sources, got %+v", response.Summary)
}
for _, item := range response.Items {
if item.ModelName == "official-a" && item.SourceCount != 2 {
t.Fatalf("official-a mirrors should aggregate into one card: %+v", item)
}
}
}
func TestBuildModelCatalogExcludesDeprecatedBaseModels(t *testing.T) {
models := []store.PlatformModel{
{ID: "preview", PlatformID: "platform", BaseModelID: "base-preview", ModelName: "gemini-preview", ModelType: store.StringList{"text_generate"}, Enabled: true},
}
platforms := []store.Platform{{ID: "platform", Provider: "gemini", Status: "enabled"}}
baseModels := []store.BaseModel{{ID: "base-preview", InvocationName: "gemini-preview", Status: "deprecated"}}
response := buildModelCatalog(models, platforms, nil, nil, nil, nil, baseModels)
if response.Summary.ModelCount != 0 || response.Summary.SourceCount != 0 {
t.Fatalf("deprecated base models must not create new catalog cards: %+v", response.Summary)
}
}
func TestBuildModelCatalogUsesBaseModelProviderForProviderFilters(t *testing.T) {
models := []store.PlatformModel{
{
ID: "glm-volces",
PlatformID: "platform-volces",
BaseModelID: "base-glm",
ModelName: "glm-4.7",
ModelAlias: "GLM-4.7",
ModelType: store.StringList{"text_generate"},
DisplayName: "GLM-4.7",
Enabled: true,
},
{
ID: "glm-zhipu",
PlatformID: "platform-zhipu",
BaseModelID: "base-glm",
ModelName: "glm-4.7",
ModelAlias: "GLM-4.7",
ModelType: store.StringList{"text_generate"},
DisplayName: "GLM-4.7",
Enabled: true,
},
}
platforms := []store.Platform{
{ID: "platform-volces", Provider: "volces-openai", Name: "火山引擎(OpenAI兼容)", Status: "enabled"},
{ID: "platform-zhipu", Provider: "zhipu-openai", Name: "智谱官方", Status: "enabled"},
}
providers := []store.CatalogProvider{
{ProviderKey: "volces-openai", DisplayName: "火山引擎(OpenAI兼容)", IconPath: "volces.png"},
{ProviderKey: "zhipu-openai", DisplayName: "智谱AI", IconPath: "zhipu.png"},
}
baseModels := []store.BaseModel{
{ID: "base-glm", ProviderKey: "zhipu-openai", ProviderModelName: "glm-4.7", ModelAlias: "GLM-4.7"},
}
response := buildModelCatalog(models, platforms, providers, nil, nil, nil, baseModels)
if response.Summary.ModelCount != 1 || response.Summary.SourceCount != 2 {
t.Fatalf("unexpected summary: %+v", response.Summary)
}
item := response.Items[0]
if len(item.ProviderKeys) != 1 || item.ProviderKeys[0] != "zhipu-openai" {
t.Fatalf("expected model provider zhipu-openai only, got %+v", item.ProviderKeys)
}
if len(item.Providers) != 1 || item.Providers[0].Name != "智谱AI" || item.Providers[0].SourceCount != 2 {
t.Fatalf("expected provider summary to aggregate both sources under model provider, got %+v", item.Providers)
}
if !hasFilterCount(response.Filters.Providers, "zhipu-openai", 1) {
t.Fatalf("expected zhipu provider filter count 1, got %+v", response.Filters.Providers)
}
if hasFilterCount(response.Filters.Providers, "volces-openai", 1) {
t.Fatalf("did not expect platform provider in model provider filters: %+v", response.Filters.Providers)
}
}
func TestBuildModelCatalogIncludes3DModelCapabilityFilter(t *testing.T) {
models := []store.PlatformModel{
{
ID: "tripo-image-to-model",
PlatformID: "platform-tripo",
ModelName: "tripo-3d",
ModelAlias: "Tripo 3D",
ModelType: store.StringList{"image_to_model"},
DisplayName: "Tripo 3D",
Enabled: true,
},
}
platforms := []store.Platform{
{ID: "platform-tripo", Provider: "tripo3d", Name: "Tripo3D", Status: "enabled"},
}
response := buildModelCatalog(models, platforms, nil, nil, nil, nil, nil)
if !hasFilterCount(response.Filters.Capabilities, "image_to_model", 1) {
t.Fatalf("expected 3D model capability filter, got %+v", response.Filters.Capabilities)
}
if hasFilterCount(response.Filters.Capabilities, "image_generate", 1) {
t.Fatalf("did not expect image_to_model to be classified as image: %+v", response.Filters.Capabilities)
}
if len(response.Items) != 1 || len(response.Items[0].CapabilityTags) != 1 || response.Items[0].CapabilityTags[0] != "图生 3D" {
t.Fatalf("expected image-to-model capability tag, got %+v", response.Items)
}
}
func TestBuildModelCatalogKeepsDistinctCapabilityFilters(t *testing.T) {
models := []store.PlatformModel{
{ID: "image-generate", PlatformID: "platform-a", ModelName: "image-generate", ModelAlias: "Image Generate", ModelType: store.StringList{"image_generate"}, DisplayName: "Image Generate", Enabled: true},
{ID: "image-analysis", PlatformID: "platform-a", ModelName: "image-analysis", ModelAlias: "Image Analysis", ModelType: store.StringList{"image_analysis"}, DisplayName: "Image Analysis", Enabled: true},
{ID: "video-generate", PlatformID: "platform-a", ModelName: "video-generate", ModelAlias: "Video Generate", ModelType: store.StringList{"video_generate"}, DisplayName: "Video Generate", Enabled: true},
{ID: "video-understanding", PlatformID: "platform-a", ModelName: "video-understanding", ModelAlias: "Video Understanding", ModelType: store.StringList{"video_understanding"}, DisplayName: "Video Understanding", Enabled: true},
{ID: "omni", PlatformID: "platform-a", ModelName: "omni", ModelAlias: "Omni", ModelType: store.StringList{"omni"}, DisplayName: "Omni", Enabled: true},
{ID: "omni-video", PlatformID: "platform-a", ModelName: "omni-video", ModelAlias: "Omni Video", ModelType: store.StringList{"omni_video"}, DisplayName: "Omni Video", Enabled: true},
}
platforms := []store.Platform{
{ID: "platform-a", Provider: "test", Name: "测试平台", Status: "enabled"},
}
response := buildModelCatalog(models, platforms, nil, nil, nil, nil, nil)
for _, value := range []string{"image_generate", "image_analysis", "video_generate", "video_understanding", "omni", "omni_video"} {
if !hasFilterCount(response.Filters.Capabilities, value, 1) {
t.Fatalf("expected distinct capability filter %s, got %+v", value, response.Filters.Capabilities)
}
}
if hasFilterCount(response.Filters.Capabilities, "image", 2) || hasFilterCount(response.Filters.Capabilities, "video", 2) {
t.Fatalf("did not expect broad image/video filters: %+v", response.Filters.Capabilities)
}
}
func TestBuildModelCatalogOnlyUsesEnabledPlatformModels(t *testing.T) {
models := []store.PlatformModel{
{
ID: "enabled-source",
PlatformID: "platform-enabled",
BaseModelID: "base-deepseek",
ModelName: "deepseek-chat",
ModelAlias: "DeepSeek Chat",
ModelType: store.StringList{"text_generate"},
DisplayName: "DeepSeek Chat",
Enabled: true,
},
{
ID: "disabled-model",
PlatformID: "platform-enabled",
BaseModelID: "base-deepseek",
ModelName: "deepseek-chat",
ModelAlias: "DeepSeek Chat",
ModelType: store.StringList{"text_generate"},
DisplayName: "DeepSeek Chat",
Enabled: false,
},
{
ID: "disabled-platform",
PlatformID: "platform-disabled",
BaseModelID: "base-deepseek",
ModelName: "deepseek-chat",
ModelAlias: "DeepSeek Chat",
ModelType: store.StringList{"text_generate"},
DisplayName: "DeepSeek Chat",
Enabled: true,
},
}
platforms := []store.Platform{
{ID: "platform-enabled", Provider: "aliyun-bailian-openai", Name: "阿里云百炼(OpenAI兼容)", Status: "enabled"},
{ID: "platform-disabled", Provider: "silicon-flow-openai", Name: "硅基流动", Status: "disabled"},
}
providers := []store.CatalogProvider{
{ProviderKey: "deepseek-openai", DisplayName: "DeepSeek"},
{ProviderKey: "aliyun-bailian-openai", DisplayName: "阿里云百炼(OpenAI兼容)"},
{ProviderKey: "silicon-flow-openai", DisplayName: "硅基流动"},
}
baseModels := []store.BaseModel{
{ID: "base-deepseek", ProviderKey: "deepseek-openai", ProviderModelName: "deepseek-chat", ModelAlias: "DeepSeek Chat"},
}
response := buildModelCatalog(models, platforms, providers, nil, nil, nil, baseModels)
if response.Summary.ModelCount != 1 || response.Summary.SourceCount != 1 {
t.Fatalf("expected only one enabled source, got %+v", response.Summary)
}
item := response.Items[0]
if item.SourceCount != 1 || len(item.Sources) != 1 || item.Sources[0].ID != "enabled-source" {
t.Fatalf("expected only enabled source in item, got sourceCount=%d sources=%+v", item.SourceCount, item.Sources)
}
if len(item.ProviderKeys) != 1 || item.ProviderKeys[0] != "deepseek-openai" {
t.Fatalf("expected base model provider deepseek-openai, got %+v", item.ProviderKeys)
}
if !hasFilterCount(response.Filters.Providers, "deepseek-openai", 1) {
t.Fatalf("expected DeepSeek provider filter, got %+v", response.Filters.Providers)
}
if hasFilterCount(response.Filters.Providers, "aliyun-bailian-openai", 1) || hasFilterCount(response.Filters.Providers, "silicon-flow-openai", 1) {
t.Fatalf("did not expect platform provider filters: %+v", response.Filters.Providers)
}
}
func TestBillingConfigLinesShowsTextInputAndOutputPricing(t *testing.T) {
lines := billingConfigLines(map[string]any{
"text_total": map[string]any{
"basePrice": 0.01,
"formulaConfig": map[string]any{
"inputTokenPrice": 0.01,
"outputTokenPrice": 0.03,
},
},
})
if len(lines) != 2 {
t.Fatalf("expected input and output pricing lines, got %+v", lines)
}
if lines[0] != "输入 0.01/k tokens" {
t.Fatalf("unexpected input pricing line %q", lines[0])
}
if lines[1] != "输出 0.03/k tokens" {
t.Fatalf("unexpected output pricing line %q", lines[1])
}
}
func TestBillingConfigLinesShowsVideoFiveSecondBasis(t *testing.T) {
lines := billingConfigLines(map[string]any{
"video": map[string]any{
"basePrice": float64(75),
"dynamicWeight": map[string]any{"480p": float64(1), "720p": float64(2)},
},
})
if len(lines) != 1 {
t.Fatalf("expected one video pricing line, got %+v", lines)
}
if lines[0] != "视频:480p 75 / 720p 1505秒基准)" {
t.Fatalf("unexpected video pricing line %q", lines[0])
}
flatLines := billingConfigLines(map[string]any{"videoBase": float64(100)})
if len(flatLines) != 1 || flatLines[0] != "视频:100 / 5秒基准" {
t.Fatalf("unexpected flat video pricing line %+v", flatLines)
}
}
func hasFilterCount(options []ModelCatalogFilterOption, value string, count int) bool {
for _, option := range options {
if option.Value == value && option.Count == count {
return true
}
}
return false
}