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
+47
View File
@@ -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": {
+33
View File
@@ -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:
@@ -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{
{
+194 -49
View File
@@ -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]
+64 -124
View File
@@ -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()
}
@@ -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"}
@@ -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)
}
}
+23 -12
View File
@@ -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)
}
+39 -15
View File
@@ -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)
+1
View File
@@ -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
@@ -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;
@@ -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);
+3 -2
View File
@@ -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 }) {
<Card className="modelCard">
<CardContent>
<div className="modelCardTop">
<ModelIcon iconPath={props.model.iconPath} label={props.model.displayName || props.model.alias} />
<ModelIcon iconPath={props.model.iconPath} label={props.model.displayName || props.model.modelName} />
<div className="modelCardHeaderText">
<strong>{props.model.displayName || props.model.alias}</strong>
<strong>{props.model.displayName || props.model.modelName}</strong>
<p className="modelCardDescription text-xs" title={description}>{description}</p>
</div>
</div>
+5 -5
View File
@@ -1001,7 +1001,7 @@ function filterModelsByType(models: PlatformModel[], modelTypes: string[]) {
function buildModelOptions(models: PlatformModel[]): ModelOption[] {
const grouped = new Map<string, ModelOption>();
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 ?? '';
}
@@ -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<BaseModelCatalogItem | null>(null);
const [pendingResetModel, setPendingResetModel] = useState<BaseModelCatalogItem | null>(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<HTMLFormElement>) {
event.preventDefault();
@@ -213,6 +233,12 @@ export function BaseModelCatalogPanel(props: {
<option value="all"></option>
{typeOptions.map((item) => <option value={item} key={item}>{item}</option>)}
</Select>
<Select value={statusFilter} onChange={(event) => setStatusFilter(event.target.value)}>
<option value="active"> Active</option>
<option value="deprecated"> Deprecated</option>
<option value="hidden"> Hidden</option>
<option value="all"></option>
</Select>
</div>
{(props.message || localError) && <p className="formMessage">{localError || props.message}</p>}
</CardContent>
@@ -268,14 +294,17 @@ export function BaseModelCatalogPanel(props: {
{providerOptions.map((item) => <option value={item} key={item}>{providerNames.get(item) ?? item}</option>)}
</Select>
</FormItem>
<FormItem label="模型名">
<FormItem label="供应商真实名" description="实际发给上游平台的 providerModelName,可包含供应商前缀。">
<Input value={form.providerModelName} onChange={(event) => setForm({ ...form, providerModelName: event.target.value })} />
</FormItem>
<FormItem
description="作为调用和展示的模型名;多个不同平台模型设置同一别名后,可按该别名自动负载。"
label="模型别名"
>
<Input value={form.modelAlias} onChange={(event) => setForm({ ...form, modelAlias: event.target.value })} />
<FormItem description="API Token 用户推荐使用的官方模型 ID,保持官方大小写。" label="官方调用名">
<Input value={form.invocationName} onChange={(event) => setForm({ ...form, invocationName: event.target.value })} />
</FormItem>
<FormItem description="只用于模型卡片标题,不参与路由。" label="显示名">
<Input value={form.displayName} onChange={(event) => setForm({ ...form, displayName: event.target.value })} />
</FormItem>
<FormItem className="spanTwo" description="每行一个历史调用名;仅在 14 天兼容期内参与路由。" label="兼容别名">
<Textarea value={form.legacyAliasesText} onChange={(event) => setForm({ ...form, legacyAliasesText: event.target.value })} rows={3} />
</FormItem>
<FormItem className="spanTwo" label="Canonical Key">
<Input value={form.canonicalModelKey} onChange={(event) => setForm({ ...form, canonicalModelKey: event.target.value })} />
@@ -285,6 +314,29 @@ export function BaseModelCatalogPanel(props: {
{statuses.map((item) => <option value={item} key={item}>{item}</option>)}
</Select>
</FormItem>
<FormItem label="生命周期">
<Select value={form.lifecycleStage} onChange={(event) => setForm({ ...form, lifecycleStage: event.target.value })}>
{['stable', 'preview', 'deprecated', 'provider', 'internal'].map((item) => <option value={item} key={item}>{item}</option>)}
</Select>
</FormItem>
<FormItem label="替代模型">
<Input value={form.replacementModel} onChange={(event) => setForm({ ...form, replacementModel: event.target.value })} />
</FormItem>
<FormItem label="官方模型 ID">
<Input value={form.officialModelId} onChange={(event) => setForm({ ...form, officialModelId: event.target.value })} />
</FormItem>
<FormItem label="官方显示名">
<Input value={form.officialDisplayName} onChange={(event) => setForm({ ...form, officialDisplayName: event.target.value })} />
</FormItem>
<FormItem label="退役时间">
<Input value={form.retireAt} onChange={(event) => setForm({ ...form, retireAt: event.target.value })} placeholder="YYYY-MM-DD" />
</FormItem>
<FormItem label="核验时间">
<Input value={form.verifiedAt} onChange={(event) => setForm({ ...form, verifiedAt: event.target.value })} placeholder="YYYY-MM-DD" />
</FormItem>
<FormItem className="spanTwo" label="官方依据链接">
<Input value={form.sourceUrl} onChange={(event) => setForm({ ...form, sourceUrl: event.target.value })} />
</FormItem>
<FormItem label="计价规则">
<Select value={form.pricingRuleSetId || defaultPricingRuleSetId} onChange={(event) => setForm({ ...form, pricingRuleSetId: event.target.value })}>
{!props.pricingRuleSets.length && <option value=""></option>}
@@ -368,7 +420,7 @@ function ModelCard(props: {
<Pencil size={14} />
</Button>
<Button type="button" variant="destructive" size="sm" onClick={props.onDelete}>
<Button type="button" variant="destructive" size="sm" disabled={props.model.referenceCount > 0} onClick={props.onDelete}>
<Trash2 size={14} />
</Button>
@@ -378,13 +430,14 @@ function ModelCard(props: {
<Badge key="status" variant={props.model.status === 'active' ? 'success' : 'secondary'}>{props.model.status}</Badge>,
<Badge key="catalog-type" variant={isSystem ? 'secondary' : 'outline'}>{baseModelCatalogTypeLabel(props.model)}</Badge>,
...(isSystem && props.model.customizedAt ? [<Badge key="customized" variant="warning"></Badge>] : []),
<Badge key="references" variant={props.model.referenceCount ? 'warning' : 'outline'}>{props.model.referenceCount} </Badge>,
]}
chips={chips}
iconPath={readModelIconPath(props.model)}
iconText={props.model.providerKey}
meta={[props.providerName ?? props.model.providerKey, props.pricingRuleSetName, props.runtimePolicySetName, props.model.canonicalModelKey]}
subtitle={props.model.providerModelName}
title={baseModelAlias(props.model)}
subtitle={`${props.model.invocationName} · 上游 ${props.model.providerModelName}`}
title={props.model.displayName || props.model.invocationName}
/>
);
}
@@ -406,8 +459,17 @@ function modelToForm(model: BaseModelCatalogItem): ModelForm {
return {
providerKey: model.providerKey,
canonicalModelKey: model.canonicalModelKey,
invocationName: model.invocationName || model.modelAlias || model.providerModelName,
providerModelName: model.providerModelName,
modelAlias: baseModelAlias(model),
displayName: model.displayName || model.invocationName || model.providerModelName,
legacyAliasesText: (model.legacyAliases ?? []).join('\n'),
officialModelId: readString(model.metadata?.officialModelId),
officialDisplayName: readString(model.metadata?.officialDisplayName),
lifecycleStage: readString(model.metadata?.lifecycleStage) || 'stable',
replacementModel: readString(model.metadata?.replacementModel),
retireAt: readString(model.metadata?.retireAt),
sourceUrl: readString(model.metadata?.sourceUrl),
verifiedAt: readString(model.metadata?.verifiedAt),
status: model.status,
pricingRuleSetId: model.pricingRuleSetId ?? '',
runtimePolicySetId: model.runtimePolicySetId ?? '',
@@ -425,7 +487,7 @@ function readModelIconPath(model: BaseModelCatalogItem) {
}
function baseModelAlias(model?: BaseModelCatalogItem | null) {
return model?.modelAlias || model?.displayName || model?.providerModelName || '';
return model?.invocationName || model?.modelAlias || model?.providerModelName || '';
}
function primaryCapabilityType(capabilities: CapabilityEditorState) {
@@ -445,21 +507,36 @@ function formToPayload(form: ModelForm): BaseModelUpsertRequest {
const modelTypes = uniqueStrings(form.capabilities.types).filter(Boolean);
const primaryType = modelTypes[0] ?? 'text_generate';
const capabilities = syncCapabilityTypes(form.capabilities, modelTypes);
const modelAlias = form.modelAlias.trim() || form.providerModelName.trim();
const invocationName = form.invocationName.trim() || form.providerModelName.trim();
const displayName = form.displayName.trim() || invocationName;
const metadata = parseJsonObject(form.metadataJson, '元数据 JSON');
return {
providerKey: form.providerKey.trim(),
canonicalModelKey: form.canonicalModelKey.trim() || undefined,
invocationName,
providerModelName: form.providerModelName.trim(),
modelType: modelTypes.length ? modelTypes : [primaryType],
modelAlias,
modelAlias: invocationName,
displayName,
legacyAliases: uniqueStrings(form.legacyAliasesText.split(/[\n,]/)),
capabilities: capabilitiesFromForm(syncPrimaryCapability(capabilities, primaryType)),
baseBillingConfig: parseJsonObject(form.billingJson, '基准计费 JSON'),
defaultRateLimitPolicy: parseJsonObject(form.rateLimitJson, '限流 JSON'),
pricingRuleSetId: form.pricingRuleSetId.trim() || undefined,
runtimePolicySetId: form.runtimePolicySetId.trim() || undefined,
runtimePolicyOverride: parseJsonObject(form.runtimePolicyOverrideJson, '运行策略覆盖 JSON'),
metadata: { ...metadata, alias: modelAlias, originalTypes: modelTypes },
metadata: {
...metadata,
alias: invocationName,
officialModelId: form.officialModelId.trim() || invocationName,
officialDisplayName: form.officialDisplayName.trim() || displayName,
lifecycleStage: form.lifecycleStage,
replacementModel: form.replacementModel.trim() || undefined,
retireAt: form.retireAt.trim() || undefined,
sourceUrl: form.sourceUrl.trim() || undefined,
verifiedAt: form.verifiedAt.trim() || undefined,
originalTypes: modelTypes,
},
pricingVersion: Number.parseInt(form.pricingVersion, 10) || 1,
status: form.status,
};
+4 -3
View File
@@ -172,12 +172,13 @@ export function platformModelPayloads(models: BaseModelCatalogItem[], form: Plat
return {
baseModelId: model.id,
canonicalModelKey: model.canonicalModelKey,
modelName: model.providerModelName,
modelName: model.invocationName || model.providerModelName,
providerModelName,
modelAlias: preset?.modelAlias || stableModelAlias(model),
modelAlias: model.invocationName || preset?.modelAlias || stableModelAlias(model),
modelType: baseModelTypesForProvider(model, form.provider),
displayName:
preset?.displayName ||
model.displayName ||
stableModelAlias(model) ||
providerModelName ||
model.providerModelName,
@@ -199,8 +200,8 @@ export function platformModelPayloads(models: BaseModelCatalogItem[], form: Plat
export function stableModelAlias(model: BaseModelCatalogItem) {
return stripProviderPrefix(
readString(model.invocationName) ||
readString(model.modelAlias) ||
readString(model.displayName) ||
readString(model.metadata?.alias) ||
readString(readRecord(model.metadata?.rawModel)?.alias) ||
model.providerModelName ||