diff --git a/apps/api/docs/swagger.json b/apps/api/docs/swagger.json index a8684a0..a337bba 100644 --- a/apps/api/docs/swagger.json +++ b/apps/api/docs/swagger.json @@ -584,6 +584,12 @@ "$ref": "#/definitions/httpapi.ErrorEnvelope" } }, + "409": { + "description": "Conflict", + "schema": { + "$ref": "#/definitions/httpapi.ErrorEnvelope" + } + }, "500": { "description": "Internal Server Error", "schema": { @@ -10391,6 +10397,12 @@ "id": { "type": "string" }, + "legacyAliases": { + "type": "array", + "items": { + "type": "string" + } + }, "modelAlias": { "type": "string" }, @@ -10412,6 +10424,9 @@ "providerKey": { "type": "string" }, + "providerModelName": { + "type": "string" + }, "providerName": { "type": "string" }, @@ -12147,14 +12162,27 @@ "type": "object", "additionalProperties": {} }, + "displayName": { + "type": "string" + }, "id": { "type": "string" }, + "invocationName": { + "type": "string" + }, + "legacyAliases": { + "type": "array", + "items": { + "type": "string" + } + }, "metadata": { "type": "object", "additionalProperties": {} }, "modelAlias": { + "description": "Deprecated: use InvocationName.", "type": "string" }, "modelType": { @@ -12175,6 +12203,9 @@ "providerModelName": { "type": "string" }, + "referenceCount": { + "type": "integer" + }, "runtimePolicyOverride": { "type": "object", "additionalProperties": {} @@ -12218,6 +12249,15 @@ "displayName": { "type": "string" }, + "invocationName": { + "type": "string" + }, + "legacyAliases": { + "type": "array", + "items": { + "type": "string" + } + }, "metadata": { "type": "object", "additionalProperties": {} @@ -13529,7 +13569,14 @@ "id": { "type": "string" }, + "legacyAliases": { + "type": "array", + "items": { + "type": "string" + } + }, "modelAlias": { + "description": "Deprecated: use ModelName. This field mirrors the canonical invocation name during the compatibility window.", "type": "string" }, "modelName": { diff --git a/apps/api/docs/swagger.yaml b/apps/api/docs/swagger.yaml index f48e4d3..3ff0350 100644 --- a/apps/api/docs/swagger.yaml +++ b/apps/api/docs/swagger.yaml @@ -748,6 +748,10 @@ definitions: type: boolean id: type: string + legacyAliases: + items: + type: string + type: array modelAlias: type: string modelName: @@ -762,6 +766,8 @@ definitions: type: string providerKey: type: string + providerModelName: + type: string providerName: type: string rateLimits: @@ -1964,12 +1970,21 @@ definitions: defaultSnapshot: additionalProperties: {} type: object + displayName: + type: string id: type: string + invocationName: + type: string + legacyAliases: + items: + type: string + type: array metadata: additionalProperties: {} type: object modelAlias: + description: 'Deprecated: use InvocationName.' type: string modelType: items: @@ -1983,6 +1998,8 @@ definitions: type: string providerModelName: type: string + referenceCount: + type: integer runtimePolicyOverride: additionalProperties: {} type: object @@ -2013,6 +2030,12 @@ definitions: type: object displayName: type: string + invocationName: + type: string + legacyAliases: + items: + type: string + type: array metadata: additionalProperties: {} type: object @@ -2898,7 +2921,13 @@ definitions: type: boolean id: type: string + legacyAliases: + items: + type: string + type: array modelAlias: + description: 'Deprecated: use ModelName. This field mirrors the canonical + invocation name during the compatibility window.' type: string modelName: type: string @@ -3931,6 +3960,10 @@ paths: description: Not Found schema: $ref: '#/definitions/httpapi.ErrorEnvelope' + "409": + description: Conflict + schema: + $ref: '#/definitions/httpapi.ErrorEnvelope' "500": description: Internal Server Error schema: diff --git a/apps/api/internal/httpapi/catalog_handlers.go b/apps/api/internal/httpapi/catalog_handlers.go index 3affb97..e31e2bf 100644 --- a/apps/api/internal/httpapi/catalog_handlers.go +++ b/apps/api/internal/httpapi/catalog_handlers.go @@ -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 diff --git a/apps/api/internal/httpapi/core_flow_integration_test.go b/apps/api/internal/httpapi/core_flow_integration_test.go index d9f4b0c..6ccc52f 100644 --- a/apps/api/internal/httpapi/core_flow_integration_test.go +++ b/apps/api/internal/httpapi/core_flow_integration_test.go @@ -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) diff --git a/apps/api/internal/httpapi/model_catalog.go b/apps/api/internal/httpapi/model_catalog.go index 965326c..0b6a3a6 100644 --- a/apps/api/internal/httpapi/model_catalog.go +++ b/apps/api/internal/httpapi/model_catalog.go @@ -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 } diff --git a/apps/api/internal/httpapi/model_catalog_test.go b/apps/api/internal/httpapi/model_catalog_test.go index 22a7f29..ba8f98b 100644 --- a/apps/api/internal/httpapi/model_catalog_test.go +++ b/apps/api/internal/httpapi/model_catalog_test.go @@ -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{ { diff --git a/apps/api/internal/store/base_models.go b/apps/api/internal/store/base_models.go index e21bc30..3b38452 100644 --- a/apps/api/internal/store/base_models.go +++ b/apps/api/internal/store/base_models.go @@ -3,25 +3,36 @@ package store import ( "context" "encoding/json" + "fmt" "strings" "github.com/jackc/pgx/v5" ) const baseModelColumns = ` -id::text, provider_key, canonical_model_key, provider_model_name, model_type, display_name, +id::text, provider_key, canonical_model_key, invocation_name, provider_model_name, model_type, display_name, capabilities, base_billing_config, default_rate_limit_policy, COALESCE(pricing_rule_set_id::text, ''), COALESCE(runtime_policy_set_id::text, ''), runtime_policy_override, metadata, catalog_type, COALESCE(default_snapshot, '{}'::jsonb), COALESCE(customized_at::text, ''), -pricing_version, status, created_at, updated_at` +pricing_version, status, created_at, updated_at, +COALESCE(( + SELECT jsonb_agg(DISTINCT compatibility_alias.alias ORDER BY compatibility_alias.alias) + FROM model_compatibility_aliases compatibility_alias + WHERE compatibility_alias.base_model_id = base_model_catalog.id + AND compatibility_alias.active = true + AND (compatibility_alias.expires_at IS NULL OR compatibility_alias.expires_at > now()) +), '[]'::jsonb), +(SELECT count(*)::int FROM platform_models platform_model WHERE platform_model.base_model_id = base_model_catalog.id)` type BaseModelInput struct { ProviderKey string `json:"providerKey"` CanonicalModelKey string `json:"canonicalModelKey"` + InvocationName string `json:"invocationName"` ProviderModelName string `json:"providerModelName"` ModelType StringList `json:"modelType"` ModelAlias string `json:"modelAlias"` DisplayName string `json:"displayName"` + LegacyAliases StringList `json:"legacyAliases"` Capabilities map[string]any `json:"capabilities"` BaseBillingConfig map[string]any `json:"baseBillingConfig"` DefaultRateLimitPolicy map[string]any `json:"defaultRateLimitPolicy"` @@ -86,25 +97,32 @@ func (s *Store) CreateBaseModel(ctx context.Context, input BaseModelInput) (Base defaultSnapshot, _ := json.Marshal(emptyObjectIfNil(input.DefaultSnapshot)) modelType, _ := json.Marshal(input.ModelType) - return scanBaseModel(s.pool.QueryRow(ctx, ` + tx, err := s.pool.Begin(ctx) + if err != nil { + return BaseModel{}, err + } + defer tx.Rollback(ctx) + + item, err := scanBaseModel(tx.QueryRow(ctx, ` INSERT INTO base_model_catalog ( - provider_id, provider_key, canonical_model_key, provider_model_name, model_type, display_name, + provider_id, provider_key, canonical_model_key, invocation_name, provider_model_name, model_type, display_name, capabilities, base_billing_config, default_rate_limit_policy, pricing_rule_set_id, runtime_policy_set_id, runtime_policy_override, metadata, catalog_type, default_snapshot, pricing_version, status ) VALUES ( (SELECT id FROM model_catalog_providers WHERE provider_key = $1 OR provider_code = $1 LIMIT 1), - $1, $2, $3, $4::jsonb, $5, $6, $7, $8, - COALESCE(NULLIF($9, '')::uuid, (SELECT id FROM model_pricing_rule_sets WHERE rule_set_key = 'default-multimodal-v1' LIMIT 1)), - COALESCE(NULLIF($10, '')::uuid, (SELECT id FROM model_runtime_policy_sets WHERE policy_key = 'default-runtime-v1' LIMIT 1)), - $11, $12, NULLIF($13, ''), NULLIF($14::jsonb, '{}'::jsonb), $15, $16 + $1, $2, $3, $4, $5::jsonb, $6, $7, $8, $9, + COALESCE(NULLIF($10, '')::uuid, (SELECT id FROM model_pricing_rule_sets WHERE rule_set_key = 'default-multimodal-v1' LIMIT 1)), + COALESCE(NULLIF($11, '')::uuid, (SELECT id FROM model_runtime_policy_sets WHERE policy_key = 'default-runtime-v1' LIMIT 1)), + $12, $13, NULLIF($14, ''), NULLIF($15::jsonb, '{}'::jsonb), $16, $17 ) RETURNING `+baseModelColumns, input.ProviderKey, input.CanonicalModelKey, + input.InvocationName, input.ProviderModelName, string(modelType), - input.ModelAlias, + input.DisplayName, capabilities, billingConfig, rateLimitPolicy, @@ -117,6 +135,17 @@ RETURNING `+baseModelColumns, input.PricingVersion, input.Status, )) + if err != nil { + return BaseModel{}, err + } + if err := replaceBaseModelAliases(ctx, tx, item.ID, input); err != nil { + return BaseModel{}, err + } + if err := tx.Commit(ctx); err != nil { + return BaseModel{}, err + } + item.LegacyAliases = input.LegacyAliases + return item, nil } func (s *Store) UpdateBaseModel(ctx context.Context, id string, input BaseModelInput) (BaseModel, error) { @@ -129,35 +158,43 @@ func (s *Store) UpdateBaseModel(ctx context.Context, id string, input BaseModelI defaultSnapshot, _ := json.Marshal(emptyObjectIfNil(input.DefaultSnapshot)) modelType, _ := json.Marshal(input.ModelType) - return scanBaseModel(s.pool.QueryRow(ctx, ` + tx, err := s.pool.Begin(ctx) + if err != nil { + return BaseModel{}, err + } + defer tx.Rollback(ctx) + + item, err := scanBaseModel(tx.QueryRow(ctx, ` UPDATE base_model_catalog SET provider_id = (SELECT id FROM model_catalog_providers WHERE provider_key = $2 OR provider_code = $2 LIMIT 1), provider_key = $2, canonical_model_key = $3, - provider_model_name = $4, - model_type = $5::jsonb, - display_name = $6, - capabilities = $7, - base_billing_config = $8, - default_rate_limit_policy = $9, - pricing_rule_set_id = COALESCE(NULLIF($10, '')::uuid, (SELECT id FROM model_pricing_rule_sets WHERE rule_set_key = 'default-multimodal-v1' LIMIT 1)), - runtime_policy_set_id = COALESCE(NULLIF($11, '')::uuid, (SELECT id FROM model_runtime_policy_sets WHERE policy_key = 'default-runtime-v1' LIMIT 1)), - runtime_policy_override = $12, - metadata = $13, - catalog_type = NULLIF($14, ''), - default_snapshot = COALESCE(NULLIF($15::jsonb, '{}'::jsonb), default_snapshot), - customized_at = CASE WHEN NULLIF($14, '') = 'system' THEN now() ELSE NULL END, - pricing_version = $16, - status = $17, + invocation_name = $4, + provider_model_name = $5, + model_type = $6::jsonb, + display_name = $7, + capabilities = $8, + base_billing_config = $9, + default_rate_limit_policy = $10, + pricing_rule_set_id = COALESCE(NULLIF($11, '')::uuid, (SELECT id FROM model_pricing_rule_sets WHERE rule_set_key = 'default-multimodal-v1' LIMIT 1)), + runtime_policy_set_id = COALESCE(NULLIF($12, '')::uuid, (SELECT id FROM model_runtime_policy_sets WHERE policy_key = 'default-runtime-v1' LIMIT 1)), + runtime_policy_override = $13, + metadata = $14, + catalog_type = NULLIF($15, ''), + default_snapshot = COALESCE(NULLIF($16::jsonb, '{}'::jsonb), default_snapshot), + customized_at = CASE WHEN NULLIF($15, '') = 'system' THEN now() ELSE NULL END, + pricing_version = $17, + status = $18, updated_at = now() WHERE id = $1::uuid RETURNING `+baseModelColumns, id, input.ProviderKey, input.CanonicalModelKey, + input.InvocationName, input.ProviderModelName, string(modelType), - input.ModelAlias, + input.DisplayName, capabilities, billingConfig, rateLimitPolicy, @@ -170,6 +207,17 @@ RETURNING `+baseModelColumns, input.PricingVersion, input.Status, )) + if err != nil { + return BaseModel{}, err + } + if err := replaceBaseModelAliases(ctx, tx, item.ID, input); err != nil { + return BaseModel{}, err + } + if err := tx.Commit(ctx); err != nil { + return BaseModel{}, err + } + item.LegacyAliases = input.LegacyAliases + return item, nil } func (s *Store) ResetBaseModelToDefault(ctx context.Context, id string) (BaseModel, error) { @@ -194,18 +242,19 @@ UPDATE base_model_catalog SET provider_id = (SELECT id FROM model_catalog_providers WHERE provider_key = COALESCE($2::text, provider_key) OR provider_code = COALESCE($2::text, provider_key) LIMIT 1), provider_key = COALESCE($2::text, provider_key), canonical_model_key = COALESCE($3::text, canonical_model_key), - provider_model_name = COALESCE($4::text, provider_model_name), - model_type = COALESCE($5::jsonb, model_type), - display_name = COALESCE($6::text, display_name), - capabilities = COALESCE($7::jsonb, capabilities), - base_billing_config = COALESCE($8::jsonb, base_billing_config), - default_rate_limit_policy = COALESCE($9::jsonb, default_rate_limit_policy), - pricing_rule_set_id = COALESCE(NULLIF($10::text, '')::uuid, pricing_rule_set_id), - runtime_policy_set_id = COALESCE(NULLIF($11::text, '')::uuid, runtime_policy_set_id), - runtime_policy_override = COALESCE($12::jsonb, runtime_policy_override), - metadata = COALESCE($13::jsonb, metadata), - pricing_version = COALESCE($14::integer, pricing_version), - status = COALESCE($15::text, status), + invocation_name = COALESCE($4::text, invocation_name), + provider_model_name = COALESCE($5::text, provider_model_name), + model_type = COALESCE($6::jsonb, model_type), + display_name = COALESCE($7::text, display_name), + capabilities = COALESCE($8::jsonb, capabilities), + base_billing_config = COALESCE($9::jsonb, base_billing_config), + default_rate_limit_policy = COALESCE($10::jsonb, default_rate_limit_policy), + pricing_rule_set_id = COALESCE(NULLIF($11::text, '')::uuid, pricing_rule_set_id), + runtime_policy_set_id = COALESCE(NULLIF($12::text, '')::uuid, runtime_policy_set_id), + runtime_policy_override = COALESCE($13::jsonb, runtime_policy_override), + metadata = COALESCE($14::jsonb, metadata), + pricing_version = COALESCE($15::integer, pricing_version), + status = COALESCE($16::text, status), customized_at = NULL, updated_at = now() WHERE id = $1::uuid @@ -213,9 +262,10 @@ RETURNING `+baseModelColumns, id, stringFromSnapshot(snapshot, "providerKey"), stringFromSnapshot(snapshot, "canonicalModelKey"), + stringFromSnapshot(snapshot, "invocationName", "modelAlias", "providerModelName"), stringFromSnapshot(snapshot, "providerModelName"), jsonStringListFromSnapshot(snapshot, "modelType"), - stringFromSnapshot(snapshot, "modelAlias", "displayName"), + stringFromSnapshot(snapshot, "displayName", "modelAlias", "providerModelName"), jsonFromSnapshot(snapshot, "capabilities"), jsonFromSnapshot(snapshot, "baseBillingConfig"), jsonFromSnapshot(snapshot, "defaultRateLimitPolicy"), @@ -240,13 +290,14 @@ SET provider_id = ( ), provider_key = COALESCE(NULLIF(default_snapshot->>'providerKey', ''), provider_key), canonical_model_key = COALESCE(NULLIF(default_snapshot->>'canonicalModelKey', ''), canonical_model_key), + invocation_name = COALESCE(NULLIF(default_snapshot->>'invocationName', ''), NULLIF(default_snapshot->>'modelAlias', ''), invocation_name), provider_model_name = COALESCE(NULLIF(default_snapshot->>'providerModelName', ''), provider_model_name), model_type = COALESCE(NULLIF(CASE WHEN jsonb_typeof(default_snapshot->'modelType') = 'array' THEN default_snapshot->'modelType' WHEN COALESCE(default_snapshot->>'modelType', '') <> '' THEN jsonb_build_array(default_snapshot->>'modelType') ELSE NULL END, '[]'::jsonb), model_type), - display_name = COALESCE(NULLIF(COALESCE(default_snapshot->>'modelAlias', default_snapshot->>'displayName'), ''), display_name), + display_name = COALESCE(NULLIF(COALESCE(default_snapshot->>'displayName', default_snapshot->>'modelAlias'), ''), display_name), capabilities = COALESCE(default_snapshot->'capabilities', capabilities), base_billing_config = COALESCE(default_snapshot->'baseBillingConfig', base_billing_config), default_rate_limit_policy = COALESCE(default_snapshot->'defaultRateLimitPolicy', default_rate_limit_policy), @@ -269,11 +320,23 @@ RETURNING `+baseModelColumns) } func (s *Store) DeleteBaseModel(ctx context.Context, id string) error { - result, err := s.pool.Exec(ctx, `DELETE FROM base_model_catalog WHERE id = $1::uuid`, id) + result, err := s.pool.Exec(ctx, ` +DELETE FROM base_model_catalog base_model +WHERE base_model.id = $1::uuid + AND NOT EXISTS ( + SELECT 1 FROM platform_models platform_model WHERE platform_model.base_model_id = base_model.id + )`, id) if err != nil { return err } if result.RowsAffected() == 0 { + var exists bool + if err := s.pool.QueryRow(ctx, `SELECT EXISTS (SELECT 1 FROM base_model_catalog WHERE id = $1::uuid)`, id).Scan(&exists); err != nil { + return err + } + if exists { + return ErrBaseModelInUse + } return pgx.ErrNoRows } return nil @@ -294,7 +357,7 @@ func scanBaseModelRows(rows pgx.Rows) ([]BaseModel, error) { func scanBaseModel(scanner baseModelScanner) (BaseModel, error) { var item BaseModel var modelType []byte - var modelAlias string + var legacyAliases []byte var capabilities []byte var billingConfig []byte var rateLimitPolicy []byte @@ -305,9 +368,10 @@ func scanBaseModel(scanner baseModelScanner) (BaseModel, error) { &item.ID, &item.ProviderKey, &item.CanonicalModelKey, + &item.InvocationName, &item.ProviderModelName, &modelType, - &modelAlias, + &item.DisplayName, &capabilities, &billingConfig, &rateLimitPolicy, @@ -322,6 +386,8 @@ func scanBaseModel(scanner baseModelScanner) (BaseModel, error) { &item.Status, &item.CreatedAt, &item.UpdatedAt, + &legacyAliases, + &item.ReferenceCount, ); err != nil { return BaseModel{}, err } @@ -332,18 +398,20 @@ func scanBaseModel(scanner baseModelScanner) (BaseModel, error) { item.Metadata = decodeObject(metadata) item.DefaultSnapshot = decodeObject(defaultSnapshot) item.ModelType = decodeStringArray(modelType) - item.ModelAlias = modelAlias - item.DisplayName = modelAlias + item.LegacyAliases = decodeStringArray(legacyAliases) + item.ModelAlias = item.InvocationName return item, nil } func normalizeBaseModelInput(input BaseModelInput) BaseModelInput { input.ProviderKey = strings.TrimSpace(input.ProviderKey) input.CanonicalModelKey = strings.TrimSpace(input.CanonicalModelKey) + input.InvocationName = strings.TrimSpace(input.InvocationName) input.ProviderModelName = strings.TrimSpace(input.ProviderModelName) input.ModelType = uniqueStringList(input.ModelType) input.ModelAlias = strings.TrimSpace(input.ModelAlias) input.DisplayName = strings.TrimSpace(input.DisplayName) + input.LegacyAliases = normalizeCompatibilityAliases(input.LegacyAliases) input.PricingRuleSetID = strings.TrimSpace(input.PricingRuleSetID) input.RuntimePolicySetID = strings.TrimSpace(input.RuntimePolicySetID) input.CatalogType = strings.TrimSpace(input.CatalogType) @@ -351,12 +419,17 @@ func normalizeBaseModelInput(input BaseModelInput) BaseModelInput { if input.CanonicalModelKey == "" && input.ProviderKey != "" && input.ProviderModelName != "" { input.CanonicalModelKey = input.ProviderKey + ":" + input.ProviderModelName } - if input.ModelAlias == "" { - input.ModelAlias = input.DisplayName + if input.InvocationName == "" { + input.InvocationName = input.ModelAlias } - if input.ModelAlias == "" { - input.ModelAlias = input.ProviderModelName + if input.InvocationName == "" { + input.InvocationName = input.ProviderModelName } + if input.DisplayName == "" { + input.DisplayName = input.InvocationName + } + input.ModelAlias = input.InvocationName + input.LegacyAliases = withoutStrings(input.LegacyAliases, input.InvocationName) if len(input.ModelType) == 0 { input.ModelType = StringList{"text_generate"} } @@ -372,6 +445,78 @@ func normalizeBaseModelInput(input BaseModelInput) BaseModelInput { return input } +func replaceBaseModelAliases(ctx context.Context, tx pgx.Tx, baseModelID string, input BaseModelInput) error { + for _, alias := range input.LegacyAliases { + for _, modelType := range input.ModelType { + var conflictingCanonicalKey string + if err := tx.QueryRow(ctx, ` +SELECT COALESCE(( + SELECT other.canonical_model_key + FROM base_model_catalog other + WHERE other.id <> $1::uuid + AND other.invocation_name <> $4::text + AND other.model_type ? $3::text + AND ( + other.invocation_name = $2::text + OR EXISTS ( + SELECT 1 + FROM model_compatibility_aliases other_alias + WHERE other_alias.base_model_id = other.id + AND other_alias.alias = $2::text + AND other_alias.model_type = $3::text + AND other_alias.active = true + AND (other_alias.expires_at IS NULL OR other_alias.expires_at > now()) + ) + ) + LIMIT 1 +), '')`, baseModelID, alias, modelType, input.InvocationName).Scan(&conflictingCanonicalKey); err != nil { + return err + } + if conflictingCanonicalKey != "" { + return fmt.Errorf("%w: %q (%s) conflicts with %s", ErrModelAliasConflict, alias, modelType, conflictingCanonicalKey) + } + } + } + + if _, err := tx.Exec(ctx, `DELETE FROM model_compatibility_aliases WHERE base_model_id = $1::uuid`, baseModelID); err != nil { + return err + } + for _, alias := range input.LegacyAliases { + for _, modelType := range input.ModelType { + if _, err := tx.Exec(ctx, ` +INSERT INTO model_compatibility_aliases (base_model_id, alias, model_type, expires_at) +VALUES ($1::uuid, $2, $3, now() + interval '14 days')`, baseModelID, alias, modelType); err != nil { + return err + } + } + } + return nil +} + +func normalizeCompatibilityAliases(values []string) StringList { + out := make(StringList, 0, len(values)) + seen := map[string]bool{} + for _, value := range values { + value = strings.TrimSpace(value) + if value == "" || seen[value] { + continue + } + seen[value] = true + out = append(out, value) + } + return out +} + +func withoutStrings(values []string, excluded string) StringList { + out := make(StringList, 0, len(values)) + for _, value := range values { + if value != excluded { + out = append(out, value) + } + } + return out +} + func stringFromSnapshot(snapshot map[string]any, keys ...string) any { for _, key := range keys { value, ok := snapshot[key] diff --git a/apps/api/internal/store/candidates.go b/apps/api/internal/store/candidates.go index 40d6cc8..4d16ce0 100644 --- a/apps/api/internal/store/candidates.go +++ b/apps/api/internal/store/candidates.go @@ -6,7 +6,6 @@ import ( "math" "sort" "strings" - "unicode" "github.com/easyai/easyai-ai-gateway/apps/api/internal/auth" ) @@ -19,7 +18,6 @@ type ListModelCandidatesOptions struct { func (s *Store) ListModelCandidates(ctx context.Context, model string, modelType string, user *auth.User, options ...ListModelCandidatesOptions) ([]RuntimeModelCandidate, error) { exactModel := strings.TrimSpace(model) - modelMatchKey := normalizeModelMatchKey(exactModel) listOptions := normalizeListModelCandidatesOptions(modelType, options...) rows, err := s.pool.Query(ctx, ` SELECT p.id::text, p.platform_key, p.name, p.provider, @@ -32,6 +30,16 @@ func (s *Store) ListModelCandidates(ctx context.Context, model string, modelType m.id::text, COALESCE(m.base_model_id::text, ''), COALESCE(b.canonical_model_key, ''), COALESCE(NULLIF(m.provider_model_name, ''), m.model_name), m.model_name, COALESCE(m.model_alias, ''), $2::text AS requested_model_type, m.display_name, + EXISTS ( + SELECT 1 + FROM model_compatibility_aliases compatibility_alias + JOIN base_model_catalog alias_base ON alias_base.id = compatibility_alias.base_model_id + WHERE compatibility_alias.alias = $1::text + AND compatibility_alias.model_type = $2::text + AND compatibility_alias.active = true + AND (compatibility_alias.expires_at IS NULL OR compatibility_alias.expires_at > now()) + AND (compatibility_alias.base_model_id = b.id OR alias_base.invocation_name = b.invocation_name) + ) AS legacy_alias_used, CASE WHEN b.capabilities #> '{text_generate,supportedApiProtocols}' IS NOT NULL THEN jsonb_set( @@ -74,10 +82,10 @@ func (s *Store) ListModelCandidates(ctx context.Context, model string, modelType ON s.client_id = p.platform_key || ':' || $2::text || ':' || COALESCE(NULLIF(m.provider_model_name, ''), m.model_name) LEFT JOIN LATERAL ( SELECT ca.cache_affinity_key, ca.request_count, ca.input_tokens, ca.cached_input_tokens, ca.ema_hit_ratio, ca.last_hit_ratio, ca.last_observed_at - FROM unnest($4::text[]) WITH ORDINALITY AS affinity_keys(cache_affinity_key, affinity_rank) + FROM unnest($3::text[]) WITH ORDINALITY AS affinity_keys(cache_affinity_key, affinity_rank) JOIN gateway_cache_affinity_stats ca ON ca.cache_affinity_key = affinity_keys.cache_affinity_key WHERE ca.client_id = p.platform_key || ':' || $2::text || ':' || COALESCE(NULLIF(m.provider_model_name, ''), m.model_name) - AND ($5::int <= 0 OR ca.last_observed_at >= now() - ($5::int * interval '1 second')) + AND ($4::int <= 0 OR ca.last_observed_at >= now() - ($4::int * interval '1 second')) ORDER BY affinity_keys.affinity_rank ASC, ca.cached_input_tokens DESC, ca.ema_hit_ratio DESC, ca.last_observed_at DESC LIMIT 1 ) ca ON TRUE @@ -138,61 +146,25 @@ WHERE p.status = 'enabled' AND m.model_type @> jsonb_build_array($2::text) AND (p.cooldown_until IS NULL OR p.cooldown_until <= now()) AND (m.cooldown_until IS NULL OR m.cooldown_until <= now()) - AND ( - ( - $2::text IN ('audio_generate', 'text_to_speech', 'voice_clone') - AND ( - m.model_alias = $1::text - OR m.model_name = $1::text - OR b.canonical_model_key = $1::text - OR b.provider_model_name = $1::text - OR ( - NULLIF($3::text, '') IS NOT NULL - AND ( - regexp_replace(COALESCE(m.model_alias, ''), '[[:space:]]+', '', 'g') = $3::text - OR regexp_replace(COALESCE(m.model_name, ''), '[[:space:]]+', '', 'g') = $3::text - OR regexp_replace(COALESCE(b.canonical_model_key, ''), '[[:space:]]+', '', 'g') = $3::text - OR regexp_replace(COALESCE(b.provider_model_name, ''), '[[:space:]]+', '', 'g') = $3::text - ) - ) - ) - ) - OR ( - $2::text NOT IN ('audio_generate', 'text_to_speech', 'voice_clone') - AND ( - ( - COALESCE(m.model_alias, '') <> '' - AND ( - m.model_alias = $1::text - OR ( - NULLIF($3::text, '') IS NOT NULL - AND regexp_replace(COALESCE(m.model_alias, ''), '[[:space:]]+', '', 'g') = $3::text - ) - ) - ) - OR ( - m.model_name = $1::text - OR COALESCE(NULLIF(m.provider_model_name, ''), m.model_name) = $1::text - OR b.canonical_model_key = $1::text - OR b.provider_model_name = $1::text - OR ( - NULLIF($3::text, '') IS NOT NULL - AND ( - regexp_replace(COALESCE(m.model_name, ''), '[[:space:]]+', '', 'g') = $3::text - OR regexp_replace(COALESCE(NULLIF(m.provider_model_name, ''), m.model_name), '[[:space:]]+', '', 'g') = $3::text - OR regexp_replace(COALESCE(b.canonical_model_key, ''), '[[:space:]]+', '', 'g') = $3::text - OR regexp_replace(COALESCE(b.provider_model_name, ''), '[[:space:]]+', '', 'g') = $3::text - ) - ) - ) - ) - ) - ) + AND ( + b.invocation_name = $1::text + OR (b.id IS NULL AND m.model_name = $1::text) + OR EXISTS ( + SELECT 1 + FROM model_compatibility_aliases compatibility_alias + JOIN base_model_catalog alias_base ON alias_base.id = compatibility_alias.base_model_id + WHERE compatibility_alias.alias = $1::text + AND compatibility_alias.model_type = $2::text + AND compatibility_alias.active = true + AND (compatibility_alias.expires_at IS NULL OR compatibility_alias.expires_at > now()) + AND (compatibility_alias.base_model_id = b.id OR alias_base.invocation_name = b.invocation_name) + ) + ) ORDER BY effective_priority ASC, COALESCE(s.running_count, 0) ASC, COALESCE(s.waiting_count, 0) ASC, COALESCE(s.last_assigned_at, to_timestamp(0)) ASC, - m.created_at ASC`, exactModel, modelType, modelMatchKey, listOptions.CacheAffinityKeys, cacheAffinityStaleAfterSeconds(listOptions.CacheAffinityPolicy)) + m.created_at ASC`, exactModel, modelType, listOptions.CacheAffinityKeys, cacheAffinityStaleAfterSeconds(listOptions.CacheAffinityPolicy)) if err != nil { return nil, err } @@ -259,6 +231,7 @@ WHERE p.status = 'enabled' &item.ModelAlias, &item.ModelType, &item.DisplayName, + &item.LegacyAliasUsed, &capabilities, &capabilityOverride, &baseBilling, @@ -361,10 +334,31 @@ WHERE p.status = 'enabled' if len(items) == 0 { return nil, ErrNoModelCandidate } + s.recordLegacyAliasUsage(ctx, exactModel, items) sortRuntimeModelCandidates(items) return items, nil } +func (s *Store) recordLegacyAliasUsage(ctx context.Context, alias string, items []RuntimeModelCandidate) { + seen := map[string]bool{} + for _, item := range items { + if !item.LegacyAliasUsed || item.CanonicalModelKey == "" { + continue + } + key := item.CanonicalModelKey + "\x00" + item.ModelType + if seen[key] { + continue + } + seen[key] = true + _, _ = s.pool.Exec(ctx, ` +INSERT INTO model_alias_usage_metrics (alias, canonical_model_key, model_type, hit_count) +VALUES ($1, $2, $3, 1) +ON CONFLICT (alias, canonical_model_key, model_type) DO UPDATE +SET hit_count = model_alias_usage_metrics.hit_count + 1, + last_used_at = now()`, alias, item.CanonicalModelKey, item.ModelType) + } +} + func (s *Store) GetRuntimeModelCandidateForVoiceCloneDeletion(ctx context.Context, platformModelID string, platformID string) (RuntimeModelCandidate, bool, error) { platformModelID = strings.TrimSpace(platformModelID) platformID = strings.TrimSpace(platformID) @@ -741,7 +735,6 @@ func runtimeCandidateFull(candidate RuntimeModelCandidate) bool { func (s *Store) modelCandidateCooldownError(ctx context.Context, model string, modelType string) (error, error) { exactModel := strings.TrimSpace(model) - modelMatchKey := normalizeModelMatchKey(exactModel) rows, err := s.pool.Query(ctx, ` SELECT p.name, COALESCE(NULLIF(m.display_name, ''), NULLIF(m.model_alias, ''), m.model_name), @@ -756,60 +749,23 @@ WHERE p.status = 'enabled' AND p.deleted_at IS NULL AND m.enabled = true AND m.model_type @> jsonb_build_array($2::text) - AND ( - ( - $2::text IN ('audio_generate', 'text_to_speech', 'voice_clone') - AND ( - m.model_alias = $1::text - OR m.model_name = $1::text - OR b.canonical_model_key = $1::text - OR b.provider_model_name = $1::text - OR ( - NULLIF($3::text, '') IS NOT NULL - AND ( - regexp_replace(COALESCE(m.model_alias, ''), '[[:space:]]+', '', 'g') = $3::text - OR regexp_replace(COALESCE(m.model_name, ''), '[[:space:]]+', '', 'g') = $3::text - OR regexp_replace(COALESCE(b.canonical_model_key, ''), '[[:space:]]+', '', 'g') = $3::text - OR regexp_replace(COALESCE(b.provider_model_name, ''), '[[:space:]]+', '', 'g') = $3::text - ) - ) - ) - ) - OR ( - $2::text NOT IN ('audio_generate', 'text_to_speech', 'voice_clone') - AND ( - ( - COALESCE(m.model_alias, '') <> '' - AND ( - m.model_alias = $1::text - OR ( - NULLIF($3::text, '') IS NOT NULL - AND regexp_replace(COALESCE(m.model_alias, ''), '[[:space:]]+', '', 'g') = $3::text - ) - ) - ) - OR ( - COALESCE(m.model_alias, '') = '' - AND ( - m.model_name = $1::text - OR b.canonical_model_key = $1::text - OR b.provider_model_name = $1::text - OR ( - NULLIF($3::text, '') IS NOT NULL - AND ( - regexp_replace(COALESCE(m.model_name, ''), '[[:space:]]+', '', 'g') = $3::text - OR regexp_replace(COALESCE(b.canonical_model_key, ''), '[[:space:]]+', '', 'g') = $3::text - OR regexp_replace(COALESCE(b.provider_model_name, ''), '[[:space:]]+', '', 'g') = $3::text - ) - ) - ) - ) - ) - ) - ) + AND ( + b.invocation_name = $1::text + OR (b.id IS NULL AND m.model_name = $1::text) + OR EXISTS ( + SELECT 1 + FROM model_compatibility_aliases compatibility_alias + JOIN base_model_catalog alias_base ON alias_base.id = compatibility_alias.base_model_id + WHERE compatibility_alias.alias = $1::text + AND compatibility_alias.model_type = $2::text + AND compatibility_alias.active = true + AND (compatibility_alias.expires_at IS NULL OR compatibility_alias.expires_at > now()) + AND (compatibility_alias.base_model_id = b.id OR alias_base.invocation_name = b.invocation_name) + ) + ) ORDER BY GREATEST(COALESCE(p.cooldown_until, to_timestamp(0)), COALESCE(m.cooldown_until, to_timestamp(0))) DESC, p.priority ASC, - m.created_at ASC`, exactModel, modelType, modelMatchKey) + m.created_at ASC`, exactModel, modelType) if err != nil { return nil, err } @@ -866,19 +822,3 @@ func cooldownErrorMessage(scope string, name string, remainingSeconds float64, c } return message } - -func normalizeModelMatchKey(value string) string { - value = strings.TrimSpace(value) - if value == "" { - return "" - } - var builder strings.Builder - builder.Grow(len(value)) - for _, char := range value { - if unicode.IsSpace(char) { - continue - } - builder.WriteRune(char) - } - return builder.String() -} diff --git a/apps/api/internal/store/candidates_test.go b/apps/api/internal/store/candidates_test.go index b66c857..1ef7c05 100644 --- a/apps/api/internal/store/candidates_test.go +++ b/apps/api/internal/store/candidates_test.go @@ -2,13 +2,6 @@ package store import "testing" -func TestNormalizeModelMatchKeyRemovesWhitespace(t *testing.T) { - got := normalizeModelMatchKey(" doubao-5.0 图像\t编辑\n") - if got != "doubao-5.0图像编辑" { - t.Fatalf("expected whitespace-insensitive model key, got %q", got) - } -} - func TestNormalizeModelTypeListExpandsOmniVideoBaseCapabilities(t *testing.T) { got := normalizeModelTypeList([]string{"omni_video"}) want := StringList{"video_generate", "image_to_video", "omni_video"} diff --git a/apps/api/internal/store/model_identity_integration_test.go b/apps/api/internal/store/model_identity_integration_test.go new file mode 100644 index 0000000..ce47c0b --- /dev/null +++ b/apps/api/internal/store/model_identity_integration_test.go @@ -0,0 +1,119 @@ +package store + +import ( + "context" + "errors" + "os" + "strings" + "testing" + "time" +) + +func TestModelIdentityRoutingAndDeleteProtection(t *testing.T) { + databaseURL := strings.TrimSpace(os.Getenv("AI_GATEWAY_TEST_DATABASE_URL")) + if databaseURL == "" { + t.Skip("set AI_GATEWAY_TEST_DATABASE_URL to run model identity PostgreSQL integration tests") + } + + ctx := context.Background() + db, err := Connect(ctx, databaseURL) + if err != nil { + t.Fatalf("connect test database: %v", err) + } + defer db.Close() + + suffix := time.Now().UTC().Format("20060102150405.000000000") + invocationName := "official-model-" + suffix + legacyAlias := "Legacy Model " + suffix + displayName := "Model Card " + suffix + baseModel, err := db.CreateBaseModel(ctx, BaseModelInput{ + ProviderKey: "openai", + CanonicalModelKey: "openai:" + invocationName, + InvocationName: invocationName, + ProviderModelName: "provider/" + invocationName, + ModelType: StringList{"text_generate"}, + DisplayName: displayName, + LegacyAliases: StringList{legacyAlias}, + Status: "active", + }) + if err != nil { + t.Fatalf("create base model: %v", err) + } + defer func() { + _, _ = db.pool.Exec(ctx, `DELETE FROM platform_models WHERE base_model_id = $1::uuid`, baseModel.ID) + _, _ = db.pool.Exec(ctx, `DELETE FROM base_model_catalog WHERE id = $1::uuid`, baseModel.ID) + }() + + var platformID string + if err := db.pool.QueryRow(ctx, ` +SELECT id::text +FROM integration_platforms +WHERE status = 'enabled' AND deleted_at IS NULL +ORDER BY created_at ASC +LIMIT 1`).Scan(&platformID); err != nil { + t.Fatalf("find enabled platform: %v", err) + } + var platformModelID string + if err := db.pool.QueryRow(ctx, ` +INSERT INTO platform_models ( + platform_id, base_model_id, model_name, provider_model_name, model_alias, + model_type, display_name, enabled +) +VALUES ($1::uuid, $2::uuid, $3, $4, $3, '["text_generate"]'::jsonb, $5, true) +RETURNING id::text`, platformID, baseModel.ID, invocationName, "provider/"+invocationName, displayName).Scan(&platformModelID); err != nil { + t.Fatalf("bind platform model: %v", err) + } + + if err := db.DeleteBaseModel(ctx, baseModel.ID); !errors.Is(err, ErrBaseModelInUse) { + t.Fatalf("bound base model deletion should be protected: %v", err) + } + + officialCandidates, err := db.ListModelCandidates(ctx, invocationName, "text_generate", nil) + if err != nil || len(officialCandidates) != 1 || officialCandidates[0].PlatformModelID != platformModelID { + t.Fatalf("official invocation should resolve the bound source: candidates=%+v err=%v", officialCandidates, err) + } + legacyCandidates, err := db.ListModelCandidates(ctx, legacyAlias, "text_generate", nil) + if err != nil || len(legacyCandidates) != 1 || !legacyCandidates[0].LegacyAliasUsed { + t.Fatalf("registered legacy alias should resolve and be marked: candidates=%+v err=%v", legacyCandidates, err) + } + if _, err := db.ListModelCandidates(ctx, displayName, "text_generate", nil); !errors.Is(err, ErrNoModelCandidate) { + t.Fatalf("display name must not become an invocation alias: %v", err) + } + + var legacyHits int64 + if err := db.pool.QueryRow(ctx, ` +SELECT hit_count +FROM model_alias_usage_metrics +WHERE alias = $1 AND canonical_model_key = $2 AND model_type = 'text_generate'`, legacyAlias, baseModel.CanonicalModelKey).Scan(&legacyHits); err != nil { + t.Fatalf("read legacy alias metric: %v", err) + } + if legacyHits != 1 { + t.Fatalf("expected one legacy alias hit, got %d", legacyHits) + } + + deprecatedInvocation := "deprecated-model-" + suffix + deprecatedBase, err := db.CreateBaseModel(ctx, BaseModelInput{ + ProviderKey: "openai", + CanonicalModelKey: "openai:" + deprecatedInvocation, + InvocationName: deprecatedInvocation, + ProviderModelName: deprecatedInvocation, + ModelType: StringList{"text_generate"}, + DisplayName: "Deprecated Model", + Status: "deprecated", + }) + if err != nil { + t.Fatalf("create deprecated base model: %v", err) + } + defer func() { + _, _ = db.pool.Exec(ctx, `DELETE FROM base_model_catalog WHERE id = $1::uuid`, deprecatedBase.ID) + }() + if _, err := db.CreatePlatformModel(ctx, CreatePlatformModelInput{ + PlatformID: platformID, + BaseModelID: deprecatedBase.ID, + ModelName: deprecatedInvocation, + ProviderModelName: deprecatedInvocation, + ModelType: StringList{"text_generate"}, + }); !errors.Is(err, ErrInvalidPlatformModelConfiguration) { + t.Fatalf("deprecated base model must reject new bindings: %v", err) + } +} diff --git a/apps/api/internal/store/platform_models.go b/apps/api/internal/store/platform_models.go index 9eb70ca..de12da6 100644 --- a/apps/api/internal/store/platform_models.go +++ b/apps/api/internal/store/platform_models.go @@ -18,6 +18,7 @@ type modelCatalogSnapshot struct { ID string ProviderKey string CanonicalModelKey string + InvocationName string ProviderModelName string ModelType StringList DisplayName string @@ -27,6 +28,7 @@ type modelCatalogSnapshot struct { DefaultRateLimitPolicy map[string]any RuntimePolicySetID string RuntimePolicyOverride map[string]any + Status string } func (s *Store) CreatePlatformModel(ctx context.Context, input CreatePlatformModelInput) (PlatformModel, error) { @@ -95,6 +97,18 @@ func (s *Store) createPlatformModel(ctx context.Context, q platformModelQuerier, if err != nil && !IsNotFound(err) { return PlatformModel{}, err } + if base.ID != "" && base.Status != "" && base.Status != "active" { + var existingBinding bool + if err := q.QueryRow(ctx, ` +SELECT EXISTS ( + SELECT 1 FROM platform_models WHERE platform_id = $1::uuid AND base_model_id = $2::uuid +)`, input.PlatformID, base.ID).Scan(&existingBinding); err != nil { + return PlatformModel{}, err + } + if !existingBinding { + return PlatformModel{}, fmt.Errorf("%w: base model %q is %s and cannot be newly bound", ErrInvalidPlatformModelConfiguration, base.InvocationName, base.Status) + } + } if len(input.ModelType) == 0 { input.ModelType = base.ModelType } @@ -102,7 +116,9 @@ func (s *Store) createPlatformModel(ctx context.Context, q platformModelQuerier, if len(input.ModelType) == 0 { input.ModelType = StringList{"text_generate"} } - if input.ModelName == "" { + if base.InvocationName != "" { + input.ModelName = base.InvocationName + } else if input.ModelName == "" { input.ModelName = base.ProviderModelName } if input.ProviderModelName == "" { @@ -370,9 +386,9 @@ func (s *Store) lookupBaseModel(ctx context.Context, q platformModelQuerier, id var runtimePolicyOverride []byte var modelTypeBytes []byte err := q.QueryRow(ctx, ` -SELECT id::text, provider_key, canonical_model_key, provider_model_name, model_type, display_name, +SELECT id::text, provider_key, canonical_model_key, invocation_name, provider_model_name, model_type, display_name, capabilities, base_billing_config, COALESCE(pricing_rule_set_id::text, ''), default_rate_limit_policy, - COALESCE(runtime_policy_set_id::text, ''), runtime_policy_override + COALESCE(runtime_policy_set_id::text, ''), runtime_policy_override, status FROM base_model_catalog WHERE ($1 <> '' AND id = NULLIF($1, '')::uuid) OR ($2 <> '' AND canonical_model_key = $2) @@ -382,6 +398,7 @@ LIMIT 1`, strings.TrimSpace(id), strings.TrimSpace(canonicalKey), strings.TrimSp &item.ID, &item.ProviderKey, &item.CanonicalModelKey, + &item.InvocationName, &item.ProviderModelName, &modelTypeBytes, &item.DisplayName, @@ -391,6 +408,7 @@ LIMIT 1`, strings.TrimSpace(id), strings.TrimSpace(canonicalKey), strings.TrimSp &rateLimitPolicy, &item.RuntimePolicySetID, &runtimePolicyOverride, + &item.Status, ) if err != nil { if err == pgx.ErrNoRows { @@ -407,15 +425,8 @@ LIMIT 1`, strings.TrimSpace(id), strings.TrimSpace(canonicalKey), strings.TrimSp } func normalizePlatformModelAlias(alias string, base modelCatalogSnapshot) string { - alias = strings.TrimSpace(alias) - if alias == "" { - alias = firstNonEmpty(base.ProviderModelName, base.DisplayName, base.CanonicalModelKey) - } - if base.ProviderKey != "" { - alias = strings.TrimPrefix(alias, base.ProviderKey+":") - } - if alias == base.CanonicalModelKey { - alias = stripAliasPrefix(alias) + if base.InvocationName != "" { + return base.InvocationName } return strings.TrimSpace(alias) } diff --git a/apps/api/internal/store/postgres.go b/apps/api/internal/store/postgres.go index a587f7d..77cb5f4 100644 --- a/apps/api/internal/store/postgres.go +++ b/apps/api/internal/store/postgres.go @@ -69,6 +69,8 @@ var ( ErrTaskExecutionFinished = errors.New("task execution already finished") ErrTaskExecutionManualReview = errors.New("task execution requires manual review") ErrProtectedDefault = errors.New("protected default resource cannot be deleted") + ErrBaseModelInUse = errors.New("base model is still referenced by platform models") + ErrModelAliasConflict = errors.New("model compatibility alias conflicts with another model identity") ErrUserAlreadyExists = errors.New("user already exists") ErrWeakPassword = errors.New("password must be at least 8 characters") ) @@ -217,14 +219,16 @@ type CreatedAPIKey struct { } type PlatformModel struct { - ID string `json:"id"` - PlatformID string `json:"platformId"` - BaseModelID string `json:"baseModelId,omitempty"` - Provider string `json:"provider,omitempty"` - PlatformName string `json:"platformName,omitempty"` - ModelName string `json:"modelName"` - ProviderModelName string `json:"providerModelName,omitempty"` + ID string `json:"id"` + PlatformID string `json:"platformId"` + BaseModelID string `json:"baseModelId,omitempty"` + Provider string `json:"provider,omitempty"` + PlatformName string `json:"platformName,omitempty"` + ModelName string `json:"modelName"` + ProviderModelName string `json:"providerModelName,omitempty"` + // Deprecated: use ModelName. This field mirrors the canonical invocation name during the compatibility window. ModelAlias string `json:"modelAlias,omitempty"` + LegacyAliases StringList `json:"legacyAliases,omitempty"` ModelType StringList `json:"modelType"` DisplayName string `json:"displayName"` CapabilityOverride map[string]any `json:"capabilityOverride,omitempty"` @@ -284,13 +288,17 @@ type CatalogProvider struct { } type BaseModel struct { - ID string `json:"id"` - ProviderKey string `json:"providerKey"` - CanonicalModelKey string `json:"canonicalModelKey"` - ProviderModelName string `json:"providerModelName"` - ModelType StringList `json:"modelType"` + ID string `json:"id"` + ProviderKey string `json:"providerKey"` + CanonicalModelKey string `json:"canonicalModelKey"` + InvocationName string `json:"invocationName"` + ProviderModelName string `json:"providerModelName"` + ModelType StringList `json:"modelType"` + // Deprecated: use InvocationName. ModelAlias string `json:"modelAlias"` - DisplayName string `json:"-"` + DisplayName string `json:"displayName"` + LegacyAliases StringList `json:"legacyAliases,omitempty"` + ReferenceCount int `json:"referenceCount"` Capabilities map[string]any `json:"capabilities,omitempty"` BaseBillingConfig map[string]any `json:"baseBillingConfig,omitempty"` DefaultRateLimitPolicy map[string]any `json:"defaultRateLimitPolicy,omitempty"` @@ -938,7 +946,12 @@ func (s *Store) listModels(ctx context.Context, platformID string) ([]PlatformMo rows, err := s.pool.Query(ctx, ` SELECT m.id::text, m.platform_id::text, COALESCE(m.base_model_id::text, ''), p.provider, p.name, - m.model_name, COALESCE(NULLIF(m.provider_model_name, ''), m.model_name), COALESCE(m.model_alias, ''), m.model_type, m.display_name, + COALESCE(NULLIF(b.invocation_name, ''), m.model_name), + COALESCE(NULLIF(m.provider_model_name, ''), m.model_name), + COALESCE(NULLIF(b.invocation_name, ''), NULLIF(m.model_alias, ''), m.model_name), + COALESCE(b.model_type, m.model_type), + COALESCE(NULLIF(b.display_name, ''), NULLIF(m.display_name, ''), COALESCE(NULLIF(b.invocation_name, ''), m.model_name)), + COALESCE(b.legacy_aliases, '[]'::jsonb), m.capability_override, m.capabilities, COALESCE(b.capabilities, '{}'::jsonb), COALESCE(b.base_billing_config, '{}'::jsonb), COALESCE(b.pricing_rule_set_id::text, ''), COALESCE(p.pricing_rule_set_id::text, ''), m.pricing_mode, COALESCE(m.discount_factor, 0)::float8, @@ -949,7 +962,15 @@ SELECT m.id::text, m.platform_id::text, COALESCE(m.base_model_id::text, ''), p.p FROM platform_models m JOIN integration_platforms p ON p.id = m.platform_id LEFT JOIN LATERAL ( - SELECT catalog.capabilities, catalog.base_billing_config, catalog.pricing_rule_set_id + SELECT catalog.invocation_name, catalog.display_name, catalog.model_type, + catalog.capabilities, catalog.base_billing_config, catalog.pricing_rule_set_id, + COALESCE(( + SELECT jsonb_agg(DISTINCT compatibility_alias.alias ORDER BY compatibility_alias.alias) + FROM model_compatibility_aliases compatibility_alias + WHERE compatibility_alias.base_model_id = catalog.id + AND compatibility_alias.active = true + AND (compatibility_alias.expires_at IS NULL OR compatibility_alias.expires_at > now()) + ), '[]'::jsonb) AS legacy_aliases FROM base_model_catalog catalog WHERE (m.base_model_id IS NOT NULL AND catalog.id = m.base_model_id) OR ( @@ -984,6 +1005,7 @@ ORDER BY m.model_type ASC, m.model_name ASC`, args...) var rateLimitPolicy []byte var runtimePolicyOverride []byte var modelTypeBytes []byte + var legacyAliasesBytes []byte if err := rows.Scan( &model.ID, &model.PlatformID, @@ -995,6 +1017,7 @@ ORDER BY m.model_type ASC, m.model_name ASC`, args...) &model.ModelAlias, &modelTypeBytes, &model.DisplayName, + &legacyAliasesBytes, &capabilityOverride, &capabilities, &baseCapabilities, @@ -1023,6 +1046,7 @@ ORDER BY m.model_type ASC, m.model_name ASC`, args...) model.BaseCapabilities = decodeObject(baseCapabilities) model.BaseBillingConfig = decodeObject(baseBillingConfig) model.ModelType = decodeStringArray(modelTypeBytes) + model.LegacyAliases = decodeStringArray(legacyAliasesBytes) model.BillingConfigOverride = decodeObject(billingConfigOverride) model.BillingConfig = decodeObject(billingConfig) model.PermissionConfig = decodeObject(permissionConfig) diff --git a/apps/api/internal/store/runtime_types.go b/apps/api/internal/store/runtime_types.go index cd2e7ed..c6fbede 100644 --- a/apps/api/internal/store/runtime_types.go +++ b/apps/api/internal/store/runtime_types.go @@ -139,6 +139,7 @@ type RuntimeModelCandidate struct { ModelAlias string ModelType string DisplayName string + LegacyAliasUsed bool Capabilities map[string]any CapabilityOverride map[string]any BaseBillingConfig map[string]any diff --git a/apps/api/migrations/0076_model_identity_contract.sql b/apps/api/migrations/0076_model_identity_contract.sql new file mode 100644 index 0000000..6f37934 --- /dev/null +++ b/apps/api/migrations/0076_model_identity_contract.sql @@ -0,0 +1,173 @@ +ALTER TABLE base_model_catalog + ADD COLUMN IF NOT EXISTS invocation_name text; + +UPDATE base_model_catalog +SET invocation_name = COALESCE( + NULLIF(metadata->>'officialModelId', ''), + NULLIF(provider_model_name, ''), + canonical_model_key + ) +WHERE invocation_name IS NULL OR trim(invocation_name) = ''; + +UPDATE base_model_catalog +SET display_name = COALESCE( + NULLIF(metadata->>'officialDisplayName', ''), + NULLIF(default_snapshot->>'displayName', ''), + NULLIF(display_name, ''), + invocation_name + ) +WHERE catalog_type = 'system'; + +ALTER TABLE base_model_catalog + ALTER COLUMN invocation_name SET NOT NULL; + +CREATE INDEX IF NOT EXISTS idx_base_model_catalog_invocation_name + ON base_model_catalog(invocation_name); + +CREATE TABLE IF NOT EXISTS model_compatibility_aliases ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + base_model_id uuid NOT NULL REFERENCES base_model_catalog(id) ON DELETE CASCADE, + alias text NOT NULL, + model_type text NOT NULL, + active boolean NOT NULL DEFAULT true, + expires_at timestamptz, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT model_compatibility_aliases_nonempty CHECK (trim(alias) <> ''), + CONSTRAINT model_compatibility_aliases_unique UNIQUE (base_model_id, alias, model_type) +); + +CREATE INDEX IF NOT EXISTS idx_model_compatibility_aliases_lookup + ON model_compatibility_aliases(alias, model_type) + WHERE active = true; + +CREATE TABLE IF NOT EXISTS model_alias_usage_metrics ( + alias text NOT NULL, + canonical_model_key text NOT NULL, + model_type text NOT NULL, + hit_count bigint NOT NULL DEFAULT 0, + first_used_at timestamptz NOT NULL DEFAULT now(), + last_used_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (alias, canonical_model_key, model_type) +); + +-- Preserve unambiguous historical card/call names for the 14-day compatibility +-- window. Ambiguous names are deliberately not inferred: they must be resolved +-- to one canonical identity by an administrator before they can be registered. +WITH alias_candidates AS ( + SELECT b.id AS base_model_id, + old_name.alias, + model_type.value AS model_type, + b.invocation_name + FROM base_model_catalog b + CROSS JOIN LATERAL jsonb_array_elements_text(b.model_type) AS model_type(value) + CROSS JOIN LATERAL ( + SELECT b.display_name AS alias + UNION + SELECT b.canonical_model_key AS alias + ) old_name + WHERE trim(COALESCE(old_name.alias, '')) <> '' + AND old_name.alias <> b.invocation_name +), unambiguous AS ( + SELECT alias, model_type, min(base_model_id::text)::uuid AS base_model_id + FROM alias_candidates + GROUP BY alias, model_type + HAVING count(DISTINCT invocation_name) = 1 +) +INSERT INTO model_compatibility_aliases (base_model_id, alias, model_type, expires_at) +SELECT base_model_id, alias, model_type, now() + interval '14 days' +FROM unambiguous +ON CONFLICT (base_model_id, alias, model_type) DO NOTHING; + +-- Keep the old platform model_name/model_alias values as explicit compatibility +-- aliases before canonical invocation names are written to platform_models. +WITH platform_alias_candidates AS ( + SELECT b.id AS base_model_id, + old_name.alias, + model_type.value AS model_type, + b.invocation_name + FROM platform_models pm + JOIN base_model_catalog b ON b.id = pm.base_model_id + CROSS JOIN LATERAL jsonb_array_elements_text(pm.model_type) AS model_type(value) + CROSS JOIN LATERAL ( + SELECT pm.model_name AS alias + UNION + SELECT pm.model_alias AS alias + ) old_name + WHERE trim(COALESCE(old_name.alias, '')) <> '' + AND old_name.alias <> b.invocation_name +), unambiguous AS ( + SELECT alias, model_type, min(base_model_id::text)::uuid AS base_model_id + FROM platform_alias_candidates + GROUP BY alias, model_type + HAVING count(DISTINCT invocation_name) = 1 +) +INSERT INTO model_compatibility_aliases (base_model_id, alias, model_type, expires_at) +SELECT base_model_id, alias, model_type, now() + interval '14 days' +FROM unambiguous +ON CONFLICT (base_model_id, alias, model_type) DO NOTHING; + +WITH ranked_targets AS ( + SELECT pm.id, + b.invocation_name, + COALESCE(NULLIF(b.display_name, ''), b.invocation_name) AS display_name, + row_number() OVER ( + PARTITION BY pm.platform_id, b.invocation_name + ORDER BY CASE WHEN pm.model_name = b.invocation_name THEN 0 ELSE 1 END, + pm.created_at ASC, + pm.id ASC + ) AS identity_rank + FROM platform_models pm + JOIN base_model_catalog b ON b.id = pm.base_model_id +) +UPDATE platform_models pm +SET model_name = target.invocation_name, + model_alias = target.invocation_name, + display_name = target.display_name, + updated_at = now() +FROM ranked_targets target +WHERE target.id = pm.id + AND target.identity_rank = 1 + AND NOT EXISTS ( + SELECT 1 + FROM platform_models conflict + WHERE conflict.platform_id = pm.platform_id + AND conflict.id <> pm.id + AND conflict.model_name = target.invocation_name + ); + +UPDATE base_model_catalog +SET default_snapshot = default_snapshot || jsonb_build_object( + 'invocationName', invocation_name, + 'modelAlias', invocation_name, + 'displayName', display_name + ) +WHERE catalog_type = 'system' + AND COALESCE(default_snapshot, '{}'::jsonb) <> '{}'::jsonb; + +CREATE OR REPLACE FUNCTION fill_system_base_model_default_snapshot() +RETURNS trigger AS $$ +BEGIN + IF NEW.catalog_type = 'system' AND NEW.default_snapshot IS NULL THEN + NEW.default_snapshot = jsonb_build_object( + 'providerKey', NEW.provider_key, + 'canonicalModelKey', NEW.canonical_model_key, + 'invocationName', NEW.invocation_name, + 'providerModelName', NEW.provider_model_name, + 'modelType', NEW.model_type, + 'modelAlias', NEW.invocation_name, + 'displayName', NEW.display_name, + 'capabilities', NEW.capabilities, + 'baseBillingConfig', NEW.base_billing_config, + 'defaultRateLimitPolicy', NEW.default_rate_limit_policy, + 'pricingRuleSetId', COALESCE(NEW.pricing_rule_set_id::text, ''), + 'runtimePolicySetId', COALESCE(NEW.runtime_policy_set_id::text, ''), + 'runtimePolicyOverride', NEW.runtime_policy_override, + 'metadata', NEW.metadata, + 'pricingVersion', NEW.pricing_version, + 'status', NEW.status + ); + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; diff --git a/apps/api/migrations/0077_model_lifecycle_cleanup_batch_one.sql b/apps/api/migrations/0077_model_lifecycle_cleanup_batch_one.sql new file mode 100644 index 0000000..e658ca3 --- /dev/null +++ b/apps/api/migrations/0077_model_lifecycle_cleanup_batch_one.sql @@ -0,0 +1,334 @@ +-- Gemini models that Google has retired or superseded stay callable only through +-- the compatibility window; they are no longer eligible for new catalog cards. +UPDATE base_model_catalog +SET status = 'deprecated', + metadata = metadata || jsonb_build_object( + 'officialModelId', invocation_name, + 'officialDisplayName', COALESCE(NULLIF(display_name, ''), invocation_name), + 'lifecycleStage', 'deprecated', + 'replacementModel', CASE + WHEN invocation_name = 'gemini-2.0-flash-exp-image-generation' THEN 'gemini-3.1-flash-image' + WHEN invocation_name = 'gemini-3-pro-preview' THEN 'gemini-3.1-pro-preview' + WHEN invocation_name = 'gemini-3.1-flash-lite-preview' THEN 'gemini-3.5-flash-lite' + WHEN invocation_name = 'gemini-3-pro-image-preview' THEN 'gemini-3-pro-image' + WHEN invocation_name = 'gemini-3.1-flash-image-preview' THEN 'gemini-3.1-flash-image' + WHEN invocation_name = 'gemini-3-flash-preview' THEN 'gemini-3.6-flash' + ELSE metadata->>'replacementModel' + END, + 'retireAt', '2026-07-22', + 'sourceUrl', 'https://ai.google.dev/gemini-api/docs/deprecations', + 'verifiedAt', '2026-07-22' + ), + updated_at = now() +WHERE catalog_type = 'system' + AND customized_at IS NULL + AND invocation_name IN ( + 'gemini-2.0-flash-exp-image-generation', + 'gemini-3-pro-preview', + 'gemini-3.1-flash-lite-preview', + 'gemini-3-pro-image-preview', + 'gemini-3.1-flash-image-preview', + 'gemini-3-flash-preview' + ); + +WITH targets(model_id, display_name, template_model, fallback_type, lifecycle_stage) AS ( + VALUES + ('gemini-3.1-pro-preview', 'Gemini 3.1 Pro Preview', 'gemini-3-pro-preview', 'text_generate', 'preview'), + ('gemini-3.5-flash-lite', 'Gemini 3.5 Flash-Lite', 'gemini-3.1-flash-lite-preview', 'text_generate', 'stable'), + ('gemini-3-pro-image', 'Gemini 3 Pro Image', 'gemini-3-pro-image-preview', 'image_generate', 'stable'), + ('gemini-3.1-flash-image', 'Gemini 3.1 Flash Image', 'gemini-3.1-flash-image-preview', 'image_generate', 'stable'), + ('gemini-3.6-flash', 'Gemini 3.6 Flash', 'gemini-3-flash-preview', 'text_generate', 'stable') +) +INSERT INTO base_model_catalog ( + provider_id, provider_key, canonical_model_key, invocation_name, provider_model_name, + model_type, display_name, capabilities, base_billing_config, default_rate_limit_policy, + pricing_rule_set_id, runtime_policy_set_id, runtime_policy_override, metadata, + catalog_type, pricing_version, status +) +SELECT provider.id, + 'gemini', + 'gemini:' || target.model_id, + target.model_id, + target.model_id, + COALESCE(template.model_type, jsonb_build_array(target.fallback_type)), + target.display_name, + COALESCE(template.capabilities, '{}'::jsonb), + COALESCE(template.base_billing_config, '{}'::jsonb), + COALESCE(template.default_rate_limit_policy, '{}'::jsonb), + template.pricing_rule_set_id, + template.runtime_policy_set_id, + COALESCE(template.runtime_policy_override, '{}'::jsonb), + jsonb_build_object( + 'officialModelId', target.model_id, + 'officialDisplayName', target.display_name, + 'lifecycleStage', target.lifecycle_stage, + 'sourceUrl', 'https://ai.google.dev/gemini-api/docs/deprecations', + 'verifiedAt', '2026-07-22', + 'bindingStatus', 'unverified' + ), + 'system', + COALESCE(template.pricing_version, 1), + 'active' +FROM targets target +LEFT JOIN model_catalog_providers provider ON provider.provider_key = 'gemini' OR provider.provider_code = 'gemini' +LEFT JOIN LATERAL ( + SELECT source.model_type, source.capabilities, source.base_billing_config, + source.default_rate_limit_policy, source.pricing_rule_set_id, + source.runtime_policy_set_id, source.runtime_policy_override, source.pricing_version + FROM base_model_catalog source + WHERE source.invocation_name = target.template_model + ORDER BY CASE WHEN source.provider_key = 'gemini' THEN 0 ELSE 1 END, source.updated_at DESC + LIMIT 1 +) template ON true +ON CONFLICT (canonical_model_key) DO UPDATE +SET invocation_name = CASE WHEN base_model_catalog.customized_at IS NULL THEN EXCLUDED.invocation_name ELSE base_model_catalog.invocation_name END, + provider_model_name = CASE WHEN base_model_catalog.customized_at IS NULL THEN EXCLUDED.provider_model_name ELSE base_model_catalog.provider_model_name END, + display_name = CASE WHEN base_model_catalog.customized_at IS NULL THEN EXCLUDED.display_name ELSE base_model_catalog.display_name END, + metadata = CASE WHEN base_model_catalog.customized_at IS NULL THEN base_model_catalog.metadata || EXCLUDED.metadata ELSE base_model_catalog.metadata END, + status = CASE WHEN base_model_catalog.customized_at IS NULL THEN 'active' ELSE base_model_catalog.status END, + updated_at = now(); + +-- Alibaba Cloud announced the following Qwen IDs for retirement by 2026-10-10. +UPDATE base_model_catalog +SET status = 'deprecated', + metadata = metadata || jsonb_build_object( + 'officialModelId', invocation_name, + 'officialDisplayName', COALESCE(NULLIF(display_name, ''), invocation_name), + 'lifecycleStage', 'deprecated', + 'replacementModel', CASE + WHEN invocation_name = 'qwen3-max' THEN 'qwen3.7-max' + WHEN invocation_name = 'qwen-turbo' THEN 'qwen3.7-plus' + WHEN invocation_name IN ('qwen-vl-max', 'qwen-vl-plus') THEN 'qwen3.6-flash' + WHEN invocation_name = 'qwen3-235b-a22b' THEN 'qwen3.7-plus' + ELSE metadata->>'replacementModel' + END, + 'retireAt', '2026-10-10', + 'sourceUrl', 'https://help.aliyun.com/en/model-studio/model-depreciation', + 'verifiedAt', '2026-07-22' + ), + updated_at = now() +WHERE catalog_type = 'system' + AND customized_at IS NULL + AND invocation_name IN ('qwen3-max', 'qwen-turbo', 'qwen-vl-max', 'qwen-vl-plus', 'qwen3-235b-a22b'); + +WITH targets(model_id, display_name, template_model, fallback_type) AS ( + VALUES + ('qwen3.7-max', 'Qwen3.7-Max', 'qwen3-max', 'text_generate'), + ('qwen3.7-plus', 'Qwen3.7-Plus', 'qwen3-235b-a22b', 'text_generate'), + ('qwen3.6-flash', 'Qwen3.6-Flash', 'qwen-vl-plus', 'text_generate') +) +INSERT INTO base_model_catalog ( + provider_id, provider_key, canonical_model_key, invocation_name, provider_model_name, + model_type, display_name, capabilities, base_billing_config, default_rate_limit_policy, + pricing_rule_set_id, runtime_policy_set_id, runtime_policy_override, metadata, + catalog_type, pricing_version, status +) +SELECT provider.id, + 'aliyun', + 'aliyun:' || target.model_id, + target.model_id, + target.model_id, + COALESCE(template.model_type, jsonb_build_array(target.fallback_type)), + target.display_name, + COALESCE(template.capabilities, '{}'::jsonb), + COALESCE(template.base_billing_config, '{}'::jsonb), + COALESCE(template.default_rate_limit_policy, '{}'::jsonb), + template.pricing_rule_set_id, + template.runtime_policy_set_id, + COALESCE(template.runtime_policy_override, '{}'::jsonb), + jsonb_build_object( + 'officialModelId', target.model_id, + 'officialDisplayName', target.display_name, + 'lifecycleStage', 'stable', + 'sourceUrl', 'https://help.aliyun.com/en/model-studio/model-depreciation', + 'verifiedAt', '2026-07-22', + 'bindingStatus', 'unverified' + ), + 'system', + COALESCE(template.pricing_version, 1), + 'active' +FROM targets target +LEFT JOIN model_catalog_providers provider ON provider.provider_key = 'aliyun' OR provider.provider_code = 'aliyun' +LEFT JOIN LATERAL ( + SELECT source.model_type, source.capabilities, source.base_billing_config, + source.default_rate_limit_policy, source.pricing_rule_set_id, + source.runtime_policy_set_id, source.runtime_policy_override, source.pricing_version + FROM base_model_catalog source + WHERE source.invocation_name = target.template_model + ORDER BY CASE WHEN source.provider_key = 'aliyun' THEN 0 ELSE 1 END, source.updated_at DESC + LIMIT 1 +) template ON true +ON CONFLICT (canonical_model_key) DO UPDATE +SET invocation_name = CASE WHEN base_model_catalog.customized_at IS NULL THEN EXCLUDED.invocation_name ELSE base_model_catalog.invocation_name END, + provider_model_name = CASE WHEN base_model_catalog.customized_at IS NULL THEN EXCLUDED.provider_model_name ELSE base_model_catalog.provider_model_name END, + display_name = CASE WHEN base_model_catalog.customized_at IS NULL THEN EXCLUDED.display_name ELSE base_model_catalog.display_name END, + metadata = CASE WHEN base_model_catalog.customized_at IS NULL THEN base_model_catalog.metadata || EXCLUDED.metadata ELSE base_model_catalog.metadata END, + status = CASE WHEN base_model_catalog.customized_at IS NULL THEN 'active' ELSE base_model_catalog.status END, + updated_at = now(); + +-- DeepSeek direct API has two official public IDs. Aggregator-only V4 names are +-- kept but explicitly classified as provider identities. +UPDATE base_model_catalog +SET metadata = metadata || jsonb_build_object( + 'officialModelId', invocation_name, + 'officialDisplayName', COALESCE(NULLIF(display_name, ''), invocation_name), + 'lifecycleStage', 'stable', + 'sourceUrl', 'https://api-docs.deepseek.com/quick_start/pricing-details-usd', + 'verifiedAt', '2026-07-22', + 'identityAuthority', 'official' + ), + updated_at = now() +WHERE invocation_name IN ('deepseek-chat', 'deepseek-reasoner'); + +UPDATE base_model_catalog +SET metadata = metadata || jsonb_build_object( + 'lifecycleStage', 'provider', + 'verifiedAt', '2026-07-22', + 'identityAuthority', 'provider' + ), + updated_at = now() +WHERE lower(invocation_name) LIKE 'deepseek-v4-%'; + +-- OpenRouter keeps its namespaced upstream ID, while callers use Anthropic's +-- official model ID. +WITH claude_mapping(provider_model_name, invocation_name, display_name) AS ( + VALUES + ('anthropic/claude-opus-4.5', 'claude-opus-4-5', 'Claude Opus 4.5'), + ('anthropic/claude-opus-4.6', 'claude-opus-4-6', 'Claude Opus 4.6'), + ('anthropic/claude-sonnet-4.5', 'claude-sonnet-4-5', 'Claude Sonnet 4.5'), + ('anthropic/claude-sonnet-4.6', 'claude-sonnet-4-6', 'Claude Sonnet 4.6'), + ('anthropic/claude-haiku-4.5', 'claude-haiku-4-5', 'Claude Haiku 4.5') +), affected AS ( + SELECT base.id, base.invocation_name AS old_invocation_name, mapping.invocation_name, mapping.display_name + FROM base_model_catalog base + JOIN claude_mapping mapping ON mapping.provider_model_name = base.provider_model_name + WHERE base.catalog_type = 'system' AND base.customized_at IS NULL +), old_aliases AS ( + INSERT INTO model_compatibility_aliases (base_model_id, alias, model_type, expires_at) + SELECT affected.id, affected.old_invocation_name, model_type.value, now() + interval '14 days' + FROM affected + JOIN base_model_catalog base ON base.id = affected.id + CROSS JOIN LATERAL jsonb_array_elements_text(base.model_type) AS model_type(value) + WHERE affected.old_invocation_name <> affected.invocation_name + ON CONFLICT (base_model_id, alias, model_type) DO NOTHING +) +UPDATE base_model_catalog base +SET invocation_name = affected.invocation_name, + display_name = affected.display_name, + metadata = base.metadata || jsonb_build_object( + 'officialModelId', affected.invocation_name, + 'officialDisplayName', affected.display_name, + 'lifecycleStage', 'stable', + 'sourceUrl', 'https://platform.claude.com/docs/en/about-claude/models/model-ids-and-versions', + 'verifiedAt', '2026-07-22', + 'identityAuthority', 'official' + ), + updated_at = now() +FROM affected +WHERE base.id = affected.id; + +WITH ranked_targets AS ( + SELECT platform_model.id, + base.invocation_name, + COALESCE(NULLIF(base.display_name, ''), base.invocation_name) AS display_name, + row_number() OVER ( + PARTITION BY platform_model.platform_id, base.invocation_name + ORDER BY CASE WHEN platform_model.model_name = base.invocation_name THEN 0 ELSE 1 END, + platform_model.created_at ASC, + platform_model.id ASC + ) AS identity_rank + FROM platform_models platform_model + JOIN base_model_catalog base ON base.id = platform_model.base_model_id +) +UPDATE platform_models platform_model +SET model_name = target.invocation_name, + model_alias = target.invocation_name, + display_name = target.display_name, + updated_at = now() +FROM ranked_targets target +WHERE target.id = platform_model.id + AND target.identity_rank = 1 + AND platform_model.model_name <> target.invocation_name + AND NOT EXISTS ( + SELECT 1 + FROM platform_models conflict + WHERE conflict.platform_id = platform_model.platform_id + AND conflict.id <> platform_model.id + AND conflict.model_name = target.invocation_name + ); + +-- MiniMax keeps current main and fallback families; older text/speech previews +-- remain present only for compatibility and reference-safe migration. +UPDATE base_model_catalog +SET status = 'deprecated', + metadata = metadata || jsonb_build_object( + 'lifecycleStage', 'deprecated', + 'replacementModel', CASE + WHEN lower(invocation_name) LIKE '%speech-2.5%' THEN 'speech-2.8' + ELSE 'minimax-m2.5' + END, + 'sourceUrl', 'https://platform.minimaxi.com/docs/api-reference/api-overview', + 'verifiedAt', '2026-07-22' + ), + updated_at = now() +WHERE catalog_type = 'system' + AND customized_at IS NULL + AND ( + lower(invocation_name) IN ('minimax-m2', 'minimax-m2.1', 'minimax-m2-highspeed', 'minimax-m2.1-highspeed') + OR lower(invocation_name) LIKE '%speech-2.5%preview%' + ); + +-- Consolidate duplicate qwen-max rows conservatively. A row is hidden only +-- after every safe platform binding has moved to the selected keeper. +WITH ranked AS ( + SELECT base.id, + first_value(base.id) OVER ( + PARTITION BY base.provider_key, base.invocation_name + ORDER BY (SELECT count(*) FROM platform_models model WHERE model.base_model_id = base.id) DESC, + CASE WHEN base.customized_at IS NULL THEN 0 ELSE 1 END, + base.created_at ASC, + base.id ASC + ) AS keeper_id + FROM base_model_catalog base + WHERE lower(base.invocation_name) = 'qwen-max' +), rebound AS ( + UPDATE platform_models model + SET base_model_id = ranked.keeper_id, + updated_at = now() + FROM ranked + WHERE model.base_model_id = ranked.id + AND ranked.id <> ranked.keeper_id + AND NOT EXISTS ( + SELECT 1 FROM platform_models keeper_model + WHERE keeper_model.platform_id = model.platform_id + AND keeper_model.base_model_id = ranked.keeper_id + ) + RETURNING model.id +) +UPDATE base_model_catalog base +SET status = 'hidden', + metadata = metadata || jsonb_build_object( + 'lifecycleStage', 'duplicate', + 'cleanupCandidate', true, + 'verifiedAt', '2026-07-22' + ), + updated_at = now() +FROM ranked +WHERE base.id = ranked.id + AND ranked.id <> ranked.keeper_id + AND base.catalog_type = 'system' + AND base.customized_at IS NULL + AND NOT EXISTS (SELECT 1 FROM platform_models model WHERE model.base_model_id = base.id); + +-- Release B may physically delete only candidates which remain unreferenced +-- after the observation window. Custom and manually modified rows are excluded. +UPDATE base_model_catalog base +SET metadata = metadata || jsonb_build_object( + 'cleanupCandidate', true, + 'cleanupEligibleAfter', '2026-08-05' + ), + updated_at = now() +WHERE base.status IN ('deprecated', 'hidden') + AND base.catalog_type = 'system' + AND base.customized_at IS NULL + AND NOT EXISTS (SELECT 1 FROM platform_models model WHERE model.base_model_id = base.id); diff --git a/apps/web/src/pages/ModelsPage.tsx b/apps/web/src/pages/ModelsPage.tsx index 209b535..eda5cfb 100644 --- a/apps/web/src/pages/ModelsPage.tsx +++ b/apps/web/src/pages/ModelsPage.tsx @@ -31,6 +31,7 @@ export function ModelsPage(props: { data: ConsoleData }) { model.modelName, model.description, ...model.providers.map((item) => item.name), + ...(model.sources?.flatMap((item) => [item.providerModelName, ...(item.legacyAliases ?? [])]) ?? []), ...model.capabilityTags, ] .filter(Boolean) @@ -137,9 +138,9 @@ function ModelCard(props: { model: ModelCatalogItem }) {
- +
- {props.model.displayName || props.model.alias} + {props.model.displayName || props.model.modelName}

{description}

diff --git a/apps/web/src/pages/PlaygroundPage.tsx b/apps/web/src/pages/PlaygroundPage.tsx index e671aaf..ba2a275 100644 --- a/apps/web/src/pages/PlaygroundPage.tsx +++ b/apps/web/src/pages/PlaygroundPage.tsx @@ -1001,7 +1001,7 @@ function filterModelsByType(models: PlatformModel[], modelTypes: string[]) { function buildModelOptions(models: PlatformModel[]): ModelOption[] { const grouped = new Map(); models.forEach((model) => { - const value = model.modelAlias || model.modelName; + const value = model.modelName; if (!value) return; const current = grouped.get(value); if (current) { @@ -1014,7 +1014,7 @@ function buildModelOptions(models: PlatformModel[]): ModelOption[] { } grouped.set(value, { count: 1, - label: model.modelAlias || model.displayName || model.modelName, + label: model.displayName || model.modelName, models: [model], provider: model.provider || model.platformName || '', value, @@ -1044,9 +1044,9 @@ function resolveModelOptionValue(value: unknown, modelOptions: ModelOption[]) { const direct = modelOptions.find((item) => item.value === raw); if (direct) return direct.value; const matched = modelOptions.find((item) => item.models.some((model) => ( - model.modelAlias === raw - || model.modelName === raw - || model.displayName === raw + model.modelName === raw + || model.modelAlias === raw + || model.legacyAliases?.includes(raw) ))); return matched?.value ?? ''; } diff --git a/apps/web/src/pages/admin/BaseModelCatalogPanel.tsx b/apps/web/src/pages/admin/BaseModelCatalogPanel.tsx index b157890..6f726cf 100644 --- a/apps/web/src/pages/admin/BaseModelCatalogPanel.tsx +++ b/apps/web/src/pages/admin/BaseModelCatalogPanel.tsx @@ -25,8 +25,17 @@ import { ModelCatalogCard } from './ModelCatalogCard'; type ModelForm = { providerKey: string; canonicalModelKey: string; + invocationName: string; providerModelName: string; - modelAlias: string; + displayName: string; + legacyAliasesText: string; + officialModelId: string; + officialDisplayName: string; + lifecycleStage: string; + replacementModel: string; + retireAt: string; + sourceUrl: string; + verifiedAt: string; status: string; pricingRuleSetId: string; runtimePolicySetId: string; @@ -45,8 +54,17 @@ function createEmptyForm(modelType = 'text_generate'): ModelForm { return { providerKey: '', canonicalModelKey: '', + invocationName: '', providerModelName: '', - modelAlias: '', + displayName: '', + legacyAliasesText: '', + officialModelId: '', + officialDisplayName: '', + lifecycleStage: 'stable', + replacementModel: '', + retireAt: '', + sourceUrl: '', + verifiedAt: '', status: 'active', pricingRuleSetId: '', runtimePolicySetId: '', @@ -78,6 +96,7 @@ export function BaseModelCatalogPanel(props: { const [query, setQuery] = useState(''); const [providerFilter, setProviderFilter] = useState('all'); const [typeFilter, setTypeFilter] = useState('all'); + const [statusFilter, setStatusFilter] = useState('active'); const [pendingDeleteModel, setPendingDeleteModel] = useState(null); const [pendingResetModel, setPendingResetModel] = useState(null); const [pendingResetAll, setPendingResetAll] = useState(false); @@ -106,10 +125,11 @@ export function BaseModelCatalogPanel(props: { return props.baseModels.filter((item) => { const matchesProvider = providerFilter === 'all' || item.providerKey === providerFilter; const matchesType = typeFilter === 'all' || readModelTypes(item).includes(typeFilter); - const text = `${baseModelAlias(item)} ${item.providerModelName} ${item.canonicalModelKey}`.toLowerCase(); - return matchesProvider && matchesType && (!keyword || text.includes(keyword)); + const matchesStatus = statusFilter === 'all' || item.status === statusFilter; + const text = `${item.displayName} ${item.invocationName} ${item.providerModelName} ${item.canonicalModelKey} ${(item.legacyAliases ?? []).join(' ')}`.toLowerCase(); + return matchesProvider && matchesType && matchesStatus && (!keyword || text.includes(keyword)); }); - }, [props.baseModels, providerFilter, query, typeFilter]); + }, [props.baseModels, providerFilter, query, statusFilter, typeFilter]); async function submit(event: FormEvent) { event.preventDefault(); @@ -213,6 +233,12 @@ export function BaseModelCatalogPanel(props: { {typeOptions.map((item) => )} + {(props.message || localError) &&

{localError || props.message}

}
@@ -268,14 +294,17 @@ export function BaseModelCatalogPanel(props: { {providerOptions.map((item) => )} - + setForm({ ...form, providerModelName: event.target.value })} /> - - setForm({ ...form, modelAlias: event.target.value })} /> + + setForm({ ...form, invocationName: event.target.value })} /> + + + setForm({ ...form, displayName: event.target.value })} /> + + +