feat(catalog): 统一模型调用身份并梳理生命周期

新增官方调用名、供应商真实名、显示名和兼容别名的独立契约,调整路由、目录聚合、管理端与 OpenAPI。

增加 Gemini、Qwen、DeepSeek、Claude 和 MiniMax 生命周期迁移、别名观测及引用保护,并补充数据库与聚合测试。
This commit is contained in:
2026-07-22 20:23:36 +08:00
parent e3f6c0fa3e
commit 8d86c4b3b3
20 changed files with 1282 additions and 284 deletions
@@ -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);