feat(catalog): 统一模型调用身份并梳理生命周期
新增官方调用名、供应商真实名、显示名和兼容别名的独立契约,调整路由、目录聚合、管理端与 OpenAPI。 增加 Gemini、Qwen、DeepSeek、Claude 和 MiniMax 生命周期迁移、别名观测及引用保护,并补充数据库与聚合测试。
This commit is contained in:
@@ -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": {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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{
|
||||
{
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
Reference in New Issue
Block a user