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