feat(catalog): 统一模型调用身份并梳理生命周期

新增官方调用名、供应商真实名、显示名和兼容别名的独立契约,调整路由、目录聚合、管理端与 OpenAPI。

增加 Gemini、Qwen、DeepSeek、Claude 和 MiniMax 生命周期迁移、别名观测及引用保护,并补充数据库与聚合测试。
This commit is contained in:
2026-07-22 20:23:36 +08:00
parent e3f6c0fa3e
commit 8d86c4b3b3
20 changed files with 1282 additions and 284 deletions
@@ -182,6 +182,10 @@ func (s *Server) createBaseModel(w http.ResponseWriter, r *http.Request) {
}
item, err := s.store.CreateBaseModel(r.Context(), input)
if err != nil {
if errors.Is(err, store.ErrModelAliasConflict) {
writeError(w, http.StatusConflict, err.Error(), "model_alias_conflict")
return
}
if store.IsUniqueViolation(err) {
writeError(w, http.StatusConflict, "canonical model key already exists")
return
@@ -230,6 +234,10 @@ func (s *Server) updateBaseModel(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusConflict, "canonical model key already exists")
return
}
if errors.Is(err, store.ErrModelAliasConflict) {
writeError(w, http.StatusConflict, err.Error(), "model_alias_conflict")
return
}
s.logger.Error("update base model failed", "error", err)
writeError(w, http.StatusInternalServerError, "update base model failed")
return
@@ -301,6 +309,7 @@ func (s *Server) resetAllBaseModels(w http.ResponseWriter, r *http.Request) {
// @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope
// @Failure 404 {object} ErrorEnvelope
// @Failure 409 {object} ErrorEnvelope
// @Failure 500 {object} ErrorEnvelope
// @Router /api/admin/catalog/base-models/{baseModelID} [delete]
func (s *Server) deleteBaseModel(w http.ResponseWriter, r *http.Request) {
@@ -309,6 +318,10 @@ func (s *Server) deleteBaseModel(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusNotFound, "base model not found")
return
}
if errors.Is(err, store.ErrBaseModelInUse) {
writeError(w, http.StatusConflict, "base model is still referenced by platform models", "base_model_in_use")
return
}
s.logger.Error("delete base model failed", "error", err)
writeError(w, http.StatusInternalServerError, "delete base model failed")
return
@@ -300,23 +300,41 @@ VALUES ($1, 5, '{"purpose":"core-flow"}'::jsonb)`, inviteCode); err != nil {
Items []struct {
ID string `json:"id"`
CanonicalModelKey string `json:"canonicalModelKey"`
InvocationName string `json:"invocationName"`
ProviderModelName string `json:"providerModelName"`
ModelType []string `json:"modelType"`
ModelAlias string `json:"modelAlias"`
DisplayName string `json:"displayName"`
Status string `json:"status"`
} `json:"items"`
}
doJSON(t, server.URL, http.MethodGet, "/api/admin/catalog/base-models", loginResponse.AccessToken, nil, http.StatusOK, &baseModels)
if len(baseModels.Items) < 300 {
t.Fatalf("server-main seed should include the migrated base model catalog: got %d", len(baseModels.Items))
if len(baseModels.Items) == 0 {
t.Fatal("migrated base model catalog must not be empty")
}
requiredLifecycleModels := map[string]string{
"gemini:gemini-3.6-flash": "active",
"aliyun:qwen3.7-max": "active",
}
for _, model := range baseModels.Items {
if expectedStatus, ok := requiredLifecycleModels[model.CanonicalModelKey]; ok {
if model.InvocationName == "" || model.ModelAlias != model.InvocationName || model.Status != expectedStatus {
t.Fatalf("base model identity/lifecycle mismatch: %+v", model)
}
delete(requiredLifecycleModels, model.CanonicalModelKey)
}
}
if len(requiredLifecycleModels) != 0 {
t.Fatalf("missing lifecycle-managed base models: %+v", requiredLifecycleModels)
}
requiredMiniMaxModels := map[string]struct {
providerModelName string
modelType string
alias string
}{
"minimax:speech-2.8-hd": {providerModelName: "speech-2.8-hd", modelType: "text_to_speech", alias: "MiniMax-Speech-2.8-HD"},
"minimax:speech-2.8-turbo": {providerModelName: "speech-2.8-turbo", modelType: "text_to_speech", alias: "MiniMax-Speech-2.8-Turbo"},
"minimax:voice-clone": {providerModelName: "voice_clone", modelType: "voice_clone", alias: "MiniMax-Voice-Clone"},
"minimax:speech-2.8-hd": {providerModelName: "speech-2.8-hd", modelType: "text_to_speech", alias: "speech-2.8-hd"},
"minimax:speech-2.8-turbo": {providerModelName: "speech-2.8-turbo", modelType: "text_to_speech", alias: "speech-2.8-turbo"},
"minimax:voice-clone": {providerModelName: "voice_clone", modelType: "voice_clone", alias: "voice_clone"},
}
seenMiniMaxModels := map[string]bool{}
for _, model := range baseModels.Items {
@@ -361,9 +379,11 @@ VALUES ($1, 5, '{"purpose":"core-flow"}'::jsonb)`, inviteCode); err != nil {
baseModelInput := map[string]any{
"providerKey": "openai",
"canonicalModelKey": "openai:smoke-base-" + suffixText,
"invocationName": "smoke-base-" + suffixText,
"providerModelName": "smoke-base-" + suffixText,
"modelType": []string{"text_generate"},
"modelAlias": "Smoke Base Model",
"displayName": "Smoke Base Model",
"legacyAliases": []string{"Smoke Base Model Legacy"},
"capabilities": map[string]any{"originalTypes": []string{"text_generate"}},
"metadata": map[string]any{"source": "test"},
}
@@ -376,12 +396,13 @@ VALUES ($1, 5, '{"purpose":"core-flow"}'::jsonb)`, inviteCode); err != nil {
if createdBaseModel.ID == "" || createdBaseModel.CanonicalModelKey != baseModelInput["canonicalModelKey"] {
t.Fatalf("unexpected created base model: %+v", createdBaseModel)
}
baseModelInput["modelAlias"] = "Smoke Base Model Updated"
baseModelInput["displayName"] = "Smoke Base Model Updated"
var updatedBaseModel struct {
ModelAlias string `json:"modelAlias"`
ModelAlias string `json:"modelAlias"`
DisplayName string `json:"displayName"`
}
doJSON(t, server.URL, http.MethodPatch, "/api/admin/catalog/base-models/"+createdBaseModel.ID, loginResponse.AccessToken, baseModelInput, http.StatusOK, &updatedBaseModel)
if updatedBaseModel.ModelAlias != "Smoke Base Model Updated" {
if updatedBaseModel.ModelAlias != baseModelInput["invocationName"] || updatedBaseModel.DisplayName != "Smoke Base Model Updated" {
t.Fatalf("unexpected updated base model: %+v", updatedBaseModel)
}
doJSON(t, server.URL, http.MethodDelete, "/api/admin/catalog/base-models/"+createdBaseModel.ID, loginResponse.AccessToken, nil, http.StatusNoContent, nil)
@@ -1974,9 +1995,9 @@ SELECT EXISTS (
FROM base_model_catalog model
WHERE model.provider_key = 'volces'
AND model.canonical_model_key = 'volces:doubao-seedream-5-0-pro-260628'
AND model.invocation_name = 'doubao-seedream-5-0-pro-260628'
AND model.provider_model_name = 'doubao-seedream-5-0-pro-260628'
AND model.display_name = 'Seedream-5.0-Pro'
AND model.display_name !~ '[[:space:]]'
AND model.display_name = 'Seedream 5.0 Pro'
AND model.model_type = '["image_edit","image_generate"]'::jsonb
AND model.capabilities->'image_edit'->>'input_multiple_images' = 'true'
AND model.capabilities->'image_edit'->>'input_max_images_count' = '10'
@@ -1991,7 +2012,7 @@ SELECT EXISTS (
AND model.capabilities->>'stream' = 'false'
AND model.capabilities->>'supportWebSearch' = 'false'
AND model.default_rate_limit_policy->'rules'->0->>'limit' = '500'
AND model.default_snapshot->>'modelAlias' = 'Seedream-5.0-Pro'
AND model.default_snapshot->>'modelAlias' = 'doubao-seedream-5-0-pro-260628'
AND model.default_snapshot->>'displayName' = 'Seedream 5.0 Pro'
)`).Scan(&valid); err != nil {
t.Fatalf("query Seedream 5.0 Pro catalog migration: %v", err)
@@ -2057,7 +2078,7 @@ func doAPIV1ChatCompletionAndLoadTask(t *testing.T, ctx context.Context, pool *p
responseHeaders := doJSONWithHeaders(t, baseURL, http.MethodPost, "/api/v1/chat/completions", token, payload, nil, expectedStatus, responseOut)
taskID := strings.TrimSpace(responseHeaders.Get("X-Gateway-Task-Id"))
if taskID == "" {
t.Fatal("chat completion response did not expose X-Gateway-Task-Id")
t.Fatalf("chat completion response did not expose X-Gateway-Task-Id: headers=%v body=%+v", responseHeaders, responseOut)
}
if taskDetailOut != nil {
doJSON(t, baseURL, http.MethodGet, "/api/v1/tasks/"+taskID, token, nil, http.StatusOK, taskDetailOut)
+36 -32
View File
@@ -62,17 +62,19 @@ type ModelCatalogProviderSummary struct {
}
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"`
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"`
ProviderModelName string `json:"providerModelName,omitempty"`
ModelAlias string `json:"modelAlias,omitempty"`
LegacyAliases []string `json:"legacyAliases,omitempty"`
DisplayName string `json:"displayName"`
ModelType []string `json:"modelType"`
Enabled bool `json:"enabled"`
RateLimits ModelCatalogRateLimits `json:"rateLimits"`
}
type ModelCatalogText struct {
@@ -202,10 +204,10 @@ func buildModelCatalog(
sourceCount := 0
for _, model := range models {
platform := platformMap[model.PlatformID]
if !catalogSourceEnabled(model, platform) {
baseModel := baseModelMap[model.BaseModelID]
if !catalogSourceEnabled(model, platform, baseModel) {
continue
}
baseModel := baseModelMap[model.BaseModelID]
providerKey := modelProviderKey(model, baseModel, platform)
provider := providerMap[providerKey]
providerName := firstNonEmpty(
@@ -232,14 +234,14 @@ func buildModelCatalog(
rateLimits: effectiveModelRateLimits(model, platform, runtimePolicyMap),
}
key := catalogAliasKey(model)
key := catalogIdentityKey(model, baseModel)
current := groups[key]
if current == nil {
current = &catalogGroup{
key: key,
alias: firstNonEmpty(model.ModelAlias, model.DisplayName, model.ModelName),
displayName: firstNonEmpty(model.DisplayName, model.ModelAlias, model.ModelName),
modelName: model.ModelName,
alias: firstNonEmpty(baseModel.InvocationName, model.ModelName),
displayName: firstNonEmpty(baseModel.DisplayName, model.DisplayName, baseModel.InvocationName, model.ModelName),
modelName: firstNonEmpty(baseModel.InvocationName, model.ModelName),
modelType: cloneStringSlice(model.ModelType),
capabilities: cloneObject(model.Capabilities),
}
@@ -274,8 +276,8 @@ func buildModelCatalog(
}
}
func catalogSourceEnabled(model store.PlatformModel, platform store.Platform) bool {
return model.Enabled && platform.ID != "" && platform.Status == "enabled"
func catalogSourceEnabled(model store.PlatformModel, platform store.Platform, baseModel store.BaseModel) bool {
return model.Enabled && platform.ID != "" && platform.Status == "enabled" && (baseModel.ID == "" || baseModel.Status == "" || baseModel.Status == "active")
}
func modelCatalogItem(group *catalogGroup, accessRuleGroups map[string]accessRuleGroup) ModelCatalogItem {
@@ -304,17 +306,19 @@ func modelCatalogItem(group *catalogGroup, accessRuleGroups map[string]accessRul
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,
ID: source.model.ID,
PlatformID: source.model.PlatformID,
PlatformName: firstNonEmpty(source.platform.Name, source.model.PlatformName),
ProviderKey: source.providerKey,
ProviderName: source.providerName,
ModelName: firstNonEmpty(source.baseModel.InvocationName, source.model.ModelName),
ProviderModelName: source.model.ProviderModelName,
ModelAlias: firstNonEmpty(source.baseModel.InvocationName, source.model.ModelName),
LegacyAliases: cloneStringSlice(source.baseModel.LegacyAliases),
DisplayName: firstNonEmpty(source.baseModel.DisplayName, source.model.DisplayName, source.baseModel.InvocationName, source.model.ModelName),
ModelType: cloneStringSlice(source.model.ModelType),
Enabled: source.model.Enabled && source.platform.Status == "enabled",
RateLimits: source.rateLimits,
})
}
@@ -1260,8 +1264,8 @@ func modelIconPath(model store.PlatformModel, baseModel store.BaseModel, provide
)
}
func catalogAliasKey(model store.PlatformModel) string {
key := strings.ToLower(strings.TrimSpace(firstNonEmpty(model.ModelAlias, model.DisplayName, model.ModelName)))
func catalogIdentityKey(model store.PlatformModel, baseModel store.BaseModel) string {
key := strings.ToLower(strings.TrimSpace(firstNonEmpty(baseModel.InvocationName, baseModel.CanonicalModelKey, model.ModelName)))
if key == "" {
return model.ID
}
@@ -131,13 +131,24 @@ func TestBuildModelCatalogKeepsDisplayNameSeparateFromCallAlias(t *testing.T) {
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, nil)
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" {
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, " ") {
@@ -145,6 +156,46 @@ func TestBuildModelCatalogKeepsDisplayNameSeparateFromCallAlias(t *testing.T) {
}
}
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{
{