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