diff --git a/alembic/versions/20260110_2000_consolidated_schema_updates.py b/alembic/versions/20260110_2000_consolidated_schema_updates.py new file mode 100644 index 0000000..c74c5a4 --- /dev/null +++ b/alembic/versions/20260110_2000_consolidated_schema_updates.py @@ -0,0 +1,530 @@ +"""consolidated schema updates + +Revision ID: m4n5o6p7q8r9 +Revises: 02a45b66b7c4 +Create Date: 2026-01-10 20:00:00.000000 + +This migration consolidates all schema changes from 2026-01-08 to 2026-01-10: + +1. provider_api_keys: Key 直接关联 Provider (provider_id, api_formats) +2. provider_api_keys: 添加 rate_multipliers JSON 字段(按格式费率) +3. models: global_model_id 改为可空(支持独立 ProviderModel) +4. providers: 添加 timeout, max_retries, proxy(从 endpoint 迁移) +5. providers: display_name 重命名为 name,删除原 name +6. provider_api_keys: max_concurrent -> rpm_limit(并发改 RPM) +7. provider_api_keys: 健康度改为按格式存储(health_by_format, circuit_breaker_by_format) +8. provider_endpoints: 删除废弃的 rate_limit 列 +9. usage: 添加 client_response_headers 字段 +10. provider_api_keys: 删除 endpoint_id(Key 不再与 Endpoint 绑定) +11. provider_endpoints: 删除废弃的 max_concurrent 列 +12. providers: 删除废弃的 rpm_limit, rpm_used, rpm_reset_at 列 +""" + +import logging +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql +from sqlalchemy import inspect + + +# 配置日志 +alembic_logger = logging.getLogger("alembic.runtime.migration") + +revision = "m4n5o6p7q8r9" +down_revision = "02a45b66b7c4" +branch_labels = None +depends_on = None + + +def _column_exists(table_name: str, column_name: str) -> bool: + """Check if a column exists in the table""" + bind = op.get_bind() + inspector = inspect(bind) + columns = [col["name"] for col in inspector.get_columns(table_name)] + return column_name in columns + + +def _constraint_exists(table_name: str, constraint_name: str) -> bool: + """Check if a constraint exists""" + bind = op.get_bind() + inspector = inspect(bind) + fks = inspector.get_foreign_keys(table_name) + return any(fk.get("name") == constraint_name for fk in fks) + + +def _index_exists(table_name: str, index_name: str) -> bool: + """Check if an index exists""" + bind = op.get_bind() + inspector = inspect(bind) + indexes = inspector.get_indexes(table_name) + return any(idx.get("name") == index_name for idx in indexes) + + +def upgrade() -> None: + """Apply all consolidated schema changes""" + bind = op.get_bind() + + # ========== 1. provider_api_keys: 添加 provider_id 和 api_formats ========== + if not _column_exists("provider_api_keys", "provider_id"): + op.add_column("provider_api_keys", sa.Column("provider_id", sa.String(36), nullable=True)) + + # 数据迁移:从 endpoint 获取 provider_id + op.execute(""" + UPDATE provider_api_keys k + SET provider_id = e.provider_id + FROM provider_endpoints e + WHERE k.endpoint_id = e.id AND k.provider_id IS NULL + """) + + # 检查无法关联的孤儿 Key + result = bind.execute(sa.text( + "SELECT COUNT(*) FROM provider_api_keys WHERE provider_id IS NULL" + )) + orphan_count = result.scalar() or 0 + if orphan_count > 0: + # 使用 logger 记录更明显的告警 + alembic_logger.warning("=" * 60) + alembic_logger.warning(f"[MIGRATION WARNING] 发现 {orphan_count} 个无法关联 Provider 的孤儿 Key") + alembic_logger.warning("=" * 60) + alembic_logger.info("正在备份孤儿 Key 到 _orphan_api_keys_backup 表...") + + # 先备份孤儿数据到临时表,避免数据丢失 + op.execute(""" + CREATE TABLE IF NOT EXISTS _orphan_api_keys_backup AS + SELECT *, NOW() as backup_at + FROM provider_api_keys + WHERE provider_id IS NULL + """) + + # 记录备份的 Key ID + orphan_ids = bind.execute(sa.text( + "SELECT id, name FROM provider_api_keys WHERE provider_id IS NULL" + )).fetchall() + alembic_logger.info("备份的孤儿 Key 列表:") + for key_id, key_name in orphan_ids: + alembic_logger.info(f" - Key: {key_name} (ID: {key_id})") + + # 删除孤儿数据 + op.execute("DELETE FROM provider_api_keys WHERE provider_id IS NULL") + alembic_logger.info(f"已备份并删除 {orphan_count} 个孤儿 Key") + + # 提供恢复指南 + alembic_logger.warning("-" * 60) + alembic_logger.warning("[恢复指南] 如需恢复孤儿 Key:") + alembic_logger.warning(" 1. 查询备份表: SELECT * FROM _orphan_api_keys_backup;") + alembic_logger.warning(" 2. 确定正确的 provider_id") + alembic_logger.warning(" 3. 执行恢复:") + alembic_logger.warning(" INSERT INTO provider_api_keys (...)") + alembic_logger.warning(" SELECT ... FROM _orphan_api_keys_backup WHERE ...;") + alembic_logger.warning("-" * 60) + + # 设置 NOT NULL 并创建外键 + op.alter_column("provider_api_keys", "provider_id", nullable=False) + + if not _constraint_exists("provider_api_keys", "fk_provider_api_keys_provider"): + op.create_foreign_key( + "fk_provider_api_keys_provider", + "provider_api_keys", + "providers", + ["provider_id"], + ["id"], + ondelete="CASCADE", + ) + + if not _index_exists("provider_api_keys", "idx_provider_api_keys_provider_id"): + op.create_index("idx_provider_api_keys_provider_id", "provider_api_keys", ["provider_id"]) + + if not _column_exists("provider_api_keys", "api_formats"): + op.add_column("provider_api_keys", sa.Column("api_formats", sa.JSON(), nullable=True)) + + # 数据迁移:从 endpoint 获取 api_format + op.execute(""" + UPDATE provider_api_keys k + SET api_formats = json_build_array(e.api_format) + FROM provider_endpoints e + WHERE k.endpoint_id = e.id AND k.api_formats IS NULL + """) + + op.alter_column("provider_api_keys", "api_formats", nullable=False, server_default="[]") + + # 修改 endpoint_id 为可空,外键改为 SET NULL + if _constraint_exists("provider_api_keys", "provider_api_keys_endpoint_id_fkey"): + op.drop_constraint("provider_api_keys_endpoint_id_fkey", "provider_api_keys", type_="foreignkey") + op.alter_column("provider_api_keys", "endpoint_id", nullable=True) + # 不再重建外键,因为后面会删除这个字段 + + # ========== 2. provider_api_keys: 添加 rate_multipliers ========== + if not _column_exists("provider_api_keys", "rate_multipliers"): + op.add_column( + "provider_api_keys", + sa.Column("rate_multipliers", postgresql.JSON(astext_type=sa.Text()), nullable=True), + ) + + # 数据迁移:将 rate_multiplier 按 api_formats 转换 + op.execute(""" + UPDATE provider_api_keys + SET rate_multipliers = ( + SELECT jsonb_object_agg(elem, rate_multiplier) + FROM jsonb_array_elements_text(api_formats::jsonb) AS elem + ) + WHERE api_formats IS NOT NULL + AND api_formats::text != '[]' + AND api_formats::text != 'null' + AND rate_multipliers IS NULL + """) + + # ========== 3. models: global_model_id 改为可空 ========== + op.alter_column("models", "global_model_id", existing_type=sa.String(36), nullable=True) + + # ========== 4. providers: 添加 timeout, max_retries, proxy ========== + if not _column_exists("providers", "timeout"): + op.add_column( + "providers", + sa.Column("timeout", sa.Integer(), nullable=True, comment="请求超时(秒)"), + ) + + if not _column_exists("providers", "max_retries"): + op.add_column( + "providers", + sa.Column("max_retries", sa.Integer(), nullable=True, comment="最大重试次数"), + ) + + if not _column_exists("providers", "proxy"): + op.add_column( + "providers", + sa.Column("proxy", postgresql.JSONB(), nullable=True, comment="代理配置"), + ) + + # 从端点迁移数据到 provider + op.execute(""" + UPDATE providers p + SET + timeout = COALESCE( + p.timeout, + (SELECT MAX(e.timeout) FROM provider_endpoints e WHERE e.provider_id = p.id AND e.timeout IS NOT NULL), + 300 + ), + max_retries = COALESCE( + p.max_retries, + (SELECT MAX(e.max_retries) FROM provider_endpoints e WHERE e.provider_id = p.id AND e.max_retries IS NOT NULL), + 2 + ), + proxy = COALESCE( + p.proxy, + (SELECT e.proxy FROM provider_endpoints e WHERE e.provider_id = p.id AND e.proxy IS NOT NULL ORDER BY e.created_at LIMIT 1) + ) + WHERE p.timeout IS NULL OR p.max_retries IS NULL + """) + + # ========== 5. providers: display_name -> name ========== + # 注意:这里假设 display_name 已经被重命名为 name + # 如果 display_name 仍然存在,则需要执行重命名 + if _column_exists("providers", "display_name"): + # 删除旧的 name 索引 + if _index_exists("providers", "ix_providers_name"): + op.drop_index("ix_providers_name", table_name="providers") + + # 如果存在旧的 name 列,先删除 + if _column_exists("providers", "name"): + op.drop_column("providers", "name") + + # 重命名 display_name 为 name + op.alter_column("providers", "display_name", new_column_name="name") + + # 创建新索引 + op.create_index("ix_providers_name", "providers", ["name"], unique=True) + + # ========== 6. provider_api_keys: max_concurrent -> rpm_limit ========== + if _column_exists("provider_api_keys", "max_concurrent"): + op.alter_column("provider_api_keys", "max_concurrent", new_column_name="rpm_limit") + + if _column_exists("provider_api_keys", "learned_max_concurrent"): + op.alter_column("provider_api_keys", "learned_max_concurrent", new_column_name="learned_rpm_limit") + + if _column_exists("provider_api_keys", "last_concurrent_peak"): + op.alter_column("provider_api_keys", "last_concurrent_peak", new_column_name="last_rpm_peak") + + # 删除废弃字段 + for col in ["rate_limit", "daily_limit", "monthly_limit"]: + if _column_exists("provider_api_keys", col): + op.drop_column("provider_api_keys", col) + + # ========== 7. provider_api_keys: 健康度改为按格式存储 ========== + if not _column_exists("provider_api_keys", "health_by_format"): + op.add_column( + "provider_api_keys", + sa.Column( + "health_by_format", + postgresql.JSONB(astext_type=sa.Text()), + nullable=True, + comment="按API格式存储的健康度数据", + ), + ) + + if not _column_exists("provider_api_keys", "circuit_breaker_by_format"): + op.add_column( + "provider_api_keys", + sa.Column( + "circuit_breaker_by_format", + postgresql.JSONB(astext_type=sa.Text()), + nullable=True, + comment="按API格式存储的熔断器状态", + ), + ) + + # 数据迁移:如果存在旧字段,迁移数据到新结构 + if _column_exists("provider_api_keys", "health_score"): + op.execute(""" + UPDATE provider_api_keys + SET health_by_format = ( + SELECT jsonb_object_agg( + elem, + jsonb_build_object( + 'health_score', COALESCE(health_score, 1.0), + 'consecutive_failures', COALESCE(consecutive_failures, 0), + 'last_failure_at', last_failure_at, + 'request_results_window', COALESCE(request_results_window::jsonb, '[]'::jsonb) + ) + ) + FROM jsonb_array_elements_text(api_formats::jsonb) AS elem + ) + WHERE api_formats IS NOT NULL + AND api_formats::text != '[]' + AND health_by_format IS NULL + """) + + # Circuit Breaker 迁移策略: + # 不复制旧的 circuit_breaker_open 状态到所有 format,而是全部重置为 closed + # 原因:旧的单一 circuit breaker 状态可能因某一个 format 失败而打开, + # 如果复制到所有 format,会导致其他正常工作的 format 被错误标记为不可用 + if _column_exists("provider_api_keys", "circuit_breaker_open"): + op.execute(""" + UPDATE provider_api_keys + SET circuit_breaker_by_format = ( + SELECT jsonb_object_agg( + elem, + jsonb_build_object( + 'open', false, + 'open_at', NULL, + 'next_probe_at', NULL, + 'half_open_until', NULL, + 'half_open_successes', 0, + 'half_open_failures', 0 + ) + ) + FROM jsonb_array_elements_text(api_formats::jsonb) AS elem + ) + WHERE api_formats IS NOT NULL + AND api_formats::text != '[]' + AND circuit_breaker_by_format IS NULL + """) + + # 设置默认空对象 + op.execute(""" + UPDATE provider_api_keys + SET health_by_format = '{}'::jsonb + WHERE health_by_format IS NULL + """) + op.execute(""" + UPDATE provider_api_keys + SET circuit_breaker_by_format = '{}'::jsonb + WHERE circuit_breaker_by_format IS NULL + """) + + # 创建 GIN 索引 + if not _index_exists("provider_api_keys", "ix_provider_api_keys_health_by_format"): + op.create_index( + "ix_provider_api_keys_health_by_format", + "provider_api_keys", + ["health_by_format"], + postgresql_using="gin", + ) + if not _index_exists("provider_api_keys", "ix_provider_api_keys_circuit_breaker_by_format"): + op.create_index( + "ix_provider_api_keys_circuit_breaker_by_format", + "provider_api_keys", + ["circuit_breaker_by_format"], + postgresql_using="gin", + ) + + # 删除旧字段 + old_health_columns = [ + "health_score", + "consecutive_failures", + "last_failure_at", + "request_results_window", + "circuit_breaker_open", + "circuit_breaker_open_at", + "next_probe_at", + "half_open_until", + "half_open_successes", + "half_open_failures", + ] + for col in old_health_columns: + if _column_exists("provider_api_keys", col): + op.drop_column("provider_api_keys", col) + + # ========== 8. provider_endpoints: 删除废弃的 rate_limit 列 ========== + if _column_exists("provider_endpoints", "rate_limit"): + op.drop_column("provider_endpoints", "rate_limit") + + # ========== 9. usage: 添加 client_response_headers ========== + if not _column_exists("usage", "client_response_headers"): + op.add_column( + "usage", + sa.Column("client_response_headers", sa.JSON(), nullable=True), + ) + + # ========== 10. provider_api_keys: 删除 endpoint_id ========== + # Key 不再与 Endpoint 绑定,通过 provider_id + api_formats 关联 + if _column_exists("provider_api_keys", "endpoint_id"): + # 确保外键已删除(前面可能已经删除) + try: + bind = op.get_bind() + inspector = inspect(bind) + for fk in inspector.get_foreign_keys("provider_api_keys"): + constrained = fk.get("constrained_columns") or [] + if "endpoint_id" in constrained: + name = fk.get("name") + if name: + op.drop_constraint(name, "provider_api_keys", type_="foreignkey") + except Exception: + pass # 外键可能已经不存在 + op.drop_column("provider_api_keys", "endpoint_id") + + # ========== 11. provider_endpoints: 删除废弃的 max_concurrent 列 ========== + if _column_exists("provider_endpoints", "max_concurrent"): + op.drop_column("provider_endpoints", "max_concurrent") + + # ========== 12. providers: 删除废弃的 RPM 相关字段 ========== + if _column_exists("providers", "rpm_limit"): + op.drop_column("providers", "rpm_limit") + if _column_exists("providers", "rpm_used"): + op.drop_column("providers", "rpm_used") + if _column_exists("providers", "rpm_reset_at"): + op.drop_column("providers", "rpm_reset_at") + + alembic_logger.info("[OK] Consolidated migration completed successfully") + + +def downgrade() -> None: + """ + Downgrade is complex due to data migrations. + For safety, this only removes new columns without restoring old structure. + Manual intervention may be required for full rollback. + """ + bind = op.get_bind() + + # 12. 恢复 providers RPM 相关字段 + if not _column_exists("providers", "rpm_limit"): + op.add_column("providers", sa.Column("rpm_limit", sa.Integer(), nullable=True)) + if not _column_exists("providers", "rpm_used"): + op.add_column( + "providers", + sa.Column("rpm_used", sa.Integer(), server_default="0", nullable=True), + ) + if not _column_exists("providers", "rpm_reset_at"): + op.add_column( + "providers", + sa.Column("rpm_reset_at", sa.DateTime(timezone=True), nullable=True), + ) + + # 11. 恢复 provider_endpoints.max_concurrent + if not _column_exists("provider_endpoints", "max_concurrent"): + op.add_column("provider_endpoints", sa.Column("max_concurrent", sa.Integer(), nullable=True)) + + # 10. 恢复 endpoint_id + if not _column_exists("provider_api_keys", "endpoint_id"): + op.add_column("provider_api_keys", sa.Column("endpoint_id", sa.String(36), nullable=True)) + + # 9. 删除 client_response_headers + if _column_exists("usage", "client_response_headers"): + op.drop_column("usage", "client_response_headers") + + # 8. 恢复 provider_endpoints.rate_limit(如果需要) + if not _column_exists("provider_endpoints", "rate_limit"): + op.add_column("provider_endpoints", sa.Column("rate_limit", sa.Integer(), nullable=True)) + + # 7. 删除健康度 JSON 字段 + bind.execute(sa.text("DROP INDEX IF EXISTS ix_provider_api_keys_health_by_format")) + bind.execute(sa.text("DROP INDEX IF EXISTS ix_provider_api_keys_circuit_breaker_by_format")) + if _column_exists("provider_api_keys", "health_by_format"): + op.drop_column("provider_api_keys", "health_by_format") + if _column_exists("provider_api_keys", "circuit_breaker_by_format"): + op.drop_column("provider_api_keys", "circuit_breaker_by_format") + + # 6. rpm_limit -> max_concurrent(简化版:仅重命名) + if _column_exists("provider_api_keys", "rpm_limit"): + op.alter_column("provider_api_keys", "rpm_limit", new_column_name="max_concurrent") + if _column_exists("provider_api_keys", "learned_rpm_limit"): + op.alter_column("provider_api_keys", "learned_rpm_limit", new_column_name="learned_max_concurrent") + if _column_exists("provider_api_keys", "last_rpm_peak"): + op.alter_column("provider_api_keys", "last_rpm_peak", new_column_name="last_concurrent_peak") + + # 恢复已删除的字段 + if not _column_exists("provider_api_keys", "rate_limit"): + op.add_column("provider_api_keys", sa.Column("rate_limit", sa.Integer(), nullable=True)) + if not _column_exists("provider_api_keys", "daily_limit"): + op.add_column("provider_api_keys", sa.Column("daily_limit", sa.Integer(), nullable=True)) + if not _column_exists("provider_api_keys", "monthly_limit"): + op.add_column("provider_api_keys", sa.Column("monthly_limit", sa.Integer(), nullable=True)) + + # 5. name -> display_name (需要先删除索引) + if _index_exists("providers", "ix_providers_name"): + op.drop_index("ix_providers_name", table_name="providers") + op.alter_column("providers", "name", new_column_name="display_name") + # 重新添加原 name 字段 + op.add_column("providers", sa.Column("name", sa.String(100), nullable=True)) + op.execute(""" + UPDATE providers + SET name = LOWER(REPLACE(REPLACE(display_name, ' ', '_'), '-', '_')) + """) + op.alter_column("providers", "name", nullable=False) + op.create_index("ix_providers_name", "providers", ["name"], unique=True) + + # 4. 删除 providers 的 timeout, max_retries, proxy + if _column_exists("providers", "proxy"): + op.drop_column("providers", "proxy") + if _column_exists("providers", "max_retries"): + op.drop_column("providers", "max_retries") + if _column_exists("providers", "timeout"): + op.drop_column("providers", "timeout") + + # 3. models: global_model_id 改回 NOT NULL + result = bind.execute(sa.text( + "SELECT COUNT(*) FROM models WHERE global_model_id IS NULL" + )) + orphan_model_count = result.scalar() or 0 + if orphan_model_count > 0: + alembic_logger.warning(f"[WARN] 发现 {orphan_model_count} 个无 global_model_id 的独立模型,将被删除") + op.execute("DELETE FROM models WHERE global_model_id IS NULL") + alembic_logger.info(f"已删除 {orphan_model_count} 个独立模型") + op.alter_column("models", "global_model_id", nullable=False) + + # 2. 删除 rate_multipliers + if _column_exists("provider_api_keys", "rate_multipliers"): + op.drop_column("provider_api_keys", "rate_multipliers") + + # 1. 删除 provider_id 和 api_formats + if _index_exists("provider_api_keys", "idx_provider_api_keys_provider_id"): + op.drop_index("idx_provider_api_keys_provider_id", table_name="provider_api_keys") + if _constraint_exists("provider_api_keys", "fk_provider_api_keys_provider"): + op.drop_constraint("fk_provider_api_keys_provider", "provider_api_keys", type_="foreignkey") + if _column_exists("provider_api_keys", "api_formats"): + op.drop_column("provider_api_keys", "api_formats") + if _column_exists("provider_api_keys", "provider_id"): + op.drop_column("provider_api_keys", "provider_id") + + # 恢复 endpoint_id 外键(简化版:仅创建外键,不强制 NOT NULL) + if _column_exists("provider_api_keys", "endpoint_id"): + if not _constraint_exists("provider_api_keys", "provider_api_keys_endpoint_id_fkey"): + op.create_foreign_key( + "provider_api_keys_endpoint_id_fkey", + "provider_api_keys", + "provider_endpoints", + ["endpoint_id"], + ["id"], + ondelete="SET NULL", + ) + + alembic_logger.info("[OK] Downgrade completed (simplified version)") diff --git a/frontend/src/api/admin.ts b/frontend/src/api/admin.ts index 955cb34..06014fb 100644 --- a/frontend/src/api/admin.ts +++ b/frontend/src/api/admin.ts @@ -67,7 +67,6 @@ export interface GlobalModelExport { export interface ProviderExport { name: string - display_name: string description?: string | null website?: string | null billing_type?: string | null @@ -76,10 +75,13 @@ export interface ProviderExport { rpm_limit?: number | null provider_priority?: number is_active: boolean - rate_limit?: number | null concurrent_limit?: number | null + timeout?: number | null + max_retries?: number | null + proxy?: any config?: any endpoints: EndpointExport[] + api_keys: ProviderKeyExport[] models: ModelExport[] } @@ -89,27 +91,26 @@ export interface EndpointExport { headers?: any timeout?: number max_retries?: number - max_concurrent?: number | null - rate_limit?: number | null is_active: boolean custom_path?: string | null config?: any - keys: KeyExport[] + proxy?: any } -export interface KeyExport { +export interface ProviderKeyExport { api_key: string name?: string | null note?: string | null + api_formats: string[] rate_multiplier?: number + rate_multipliers?: Record | null internal_priority?: number global_priority?: number | null - max_concurrent?: number | null - rate_limit?: number | null - daily_limit?: number | null - monthly_limit?: number | null - allowed_models?: string[] | null + rpm_limit?: number | null + allowed_models?: any capabilities?: any + cache_ttl_minutes?: number + max_probe_interval_minutes?: number is_active: boolean } diff --git a/frontend/src/api/dashboard.ts b/frontend/src/api/dashboard.ts index bcc00b9..b385885 100644 --- a/frontend/src/api/dashboard.ts +++ b/frontend/src/api/dashboard.ts @@ -155,6 +155,7 @@ export interface RequestDetail { request_body?: Record provider_request_headers?: Record response_headers?: Record + client_response_headers?: Record response_body?: Record metadata?: Record // 阶梯计费信息 diff --git a/frontend/src/api/endpoints/adaptive.ts b/frontend/src/api/endpoints/adaptive.ts index f881ded..15aa381 100644 --- a/frontend/src/api/endpoints/adaptive.ts +++ b/frontend/src/api/endpoints/adaptive.ts @@ -14,7 +14,7 @@ export async function toggleAdaptiveMode( message: string key_id: string is_adaptive: boolean - max_concurrent: number | null + rpm_limit: number | null effective_limit: number | null }> { const response = await client.patch(`/api/admin/adaptive/keys/${keyId}/mode`, data) @@ -22,16 +22,16 @@ export async function toggleAdaptiveMode( } /** - * 设置 Key 的固定并发限制 + * 设置 Key 的固定 RPM 限制 */ -export async function setConcurrentLimit( +export async function setRpmLimit( keyId: string, limit: number ): Promise<{ message: string key_id: string is_adaptive: boolean - max_concurrent: number + rpm_limit: number previous_mode: string }> { const response = await client.patch(`/api/admin/adaptive/keys/${keyId}/limit`, null, { diff --git a/frontend/src/api/endpoints/endpoints.ts b/frontend/src/api/endpoints/endpoints.ts index a0775d4..6500b4c 100644 --- a/frontend/src/api/endpoints/endpoints.ts +++ b/frontend/src/api/endpoints/endpoints.ts @@ -27,15 +27,9 @@ export async function createEndpoint( api_format: string base_url: string custom_path?: string - auth_type?: string - auth_header?: string headers?: Record timeout?: number max_retries?: number - priority?: number - weight?: number - max_concurrent?: number - rate_limit?: number is_active?: boolean config?: Record proxy?: ProxyConfig | null @@ -52,16 +46,10 @@ export async function updateEndpoint( endpointId: string, data: Partial<{ base_url: string - custom_path: string - auth_type: string - auth_header: string + custom_path: string | null headers: Record timeout: number max_retries: number - priority: number - weight: number - max_concurrent: number - rate_limit: number is_active: boolean config: Record proxy: ProxyConfig | null @@ -74,7 +62,7 @@ export async function updateEndpoint( /** * 删除 Endpoint */ -export async function deleteEndpoint(endpointId: string): Promise<{ message: string; deleted_keys_count: number }> { +export async function deleteEndpoint(endpointId: string): Promise<{ message: string; affected_keys_count: number }> { const response = await client.delete(`/api/admin/endpoints/${endpointId}`) return response.data } diff --git a/frontend/src/api/endpoints/health.ts b/frontend/src/api/endpoints/health.ts index b2c4c0d..19f79c6 100644 --- a/frontend/src/api/endpoints/health.ts +++ b/frontend/src/api/endpoints/health.ts @@ -32,16 +32,21 @@ export async function getKeyHealth(keyId: string): Promise { /** * 恢复Key健康状态(一键恢复:重置健康度 + 关闭熔断器 + 取消自动禁用) + * @param keyId Key ID + * @param apiFormat 可选,指定 API 格式(如 CLAUDE、OPENAI),不指定则恢复所有格式 */ -export async function recoverKeyHealth(keyId: string): Promise<{ +export async function recoverKeyHealth(keyId: string, apiFormat?: string): Promise<{ message: string details: { + api_format?: string health_score: number circuit_breaker_open: boolean is_active: boolean } }> { - const response = await client.patch(`/api/admin/endpoints/health/keys/${keyId}`) + const response = await client.patch(`/api/admin/endpoints/health/keys/${keyId}`, null, { + params: apiFormat ? { api_format: apiFormat } : undefined + }) return response.data } diff --git a/frontend/src/api/endpoints/keys.ts b/frontend/src/api/endpoints/keys.ts index 3e33500..cf1eba1 100644 --- a/frontend/src/api/endpoints/keys.ts +++ b/frontend/src/api/endpoints/keys.ts @@ -1,5 +1,5 @@ import client from '../client' -import type { EndpointAPIKey } from './types' +import type { EndpointAPIKey, AllowedModels } from './types' /** * 能力定义类型 @@ -49,67 +49,6 @@ export async function getModelCapabilities(modelName: string): Promise { - const response = await client.get(`/api/admin/endpoints/${endpointId}/keys`) - return response.data -} - -/** - * 为 Endpoint 添加 Key - */ -export async function addEndpointKey( - endpointId: string, - data: { - endpoint_id: string - api_key: string - name: string // 密钥名称(必填) - rate_multiplier?: number // 成本倍率(默认 1.0) - internal_priority?: number // Endpoint 内部优先级(数字越小越优先) - max_concurrent?: number // 最大并发数(留空=自适应模式) - rate_limit?: number - daily_limit?: number - monthly_limit?: number - cache_ttl_minutes?: number // 缓存 TTL(分钟),0=禁用 - max_probe_interval_minutes?: number // 熔断探测间隔(分钟) - allowed_models?: string[] // 允许使用的模型列表 - capabilities?: Record // 能力标签配置 - note?: string // 备注说明(可选) - } -): Promise { - const response = await client.post(`/api/admin/endpoints/${endpointId}/keys`, data) - return response.data -} - -/** - * 更新 Endpoint Key - */ -export async function updateEndpointKey( - keyId: string, - data: Partial<{ - api_key: string - name: string // 密钥名称 - rate_multiplier: number // 成本倍率 - internal_priority: number // Endpoint 内部优先级(提供商优先模式,数字越小越优先) - global_priority: number // 全局 Key 优先级(全局 Key 优先模式,数字越小越优先) - max_concurrent: number // 最大并发数(留空=自适应模式) - rate_limit: number - daily_limit: number - monthly_limit: number - cache_ttl_minutes: number // 缓存 TTL(分钟),0=禁用 - max_probe_interval_minutes: number // 熔断探测间隔(分钟) - allowed_models: string[] | null // 允许使用的模型列表,null 表示允许所有 - capabilities: Record | null // 能力标签配置 - is_active: boolean - note: string // 备注说明 - }> -): Promise { - const response = await client.put(`/api/admin/endpoints/keys/${keyId}`, data) - return response.data -} - /** * 获取完整的 API Key(用于查看和复制) */ @@ -119,22 +58,71 @@ export async function revealEndpointKey(keyId: string): Promise<{ api_key: strin } /** - * 删除 Endpoint Key + * 删除 Key */ export async function deleteEndpointKey(keyId: string): Promise<{ message: string }> { const response = await client.delete(`/api/admin/endpoints/keys/${keyId}`) return response.data } + +// ========== Provider 级别的 Keys API ========== + + /** - * 批量更新 Endpoint Keys 的优先级(用于拖动排序) + * 获取 Provider 的所有 Keys */ -export async function batchUpdateKeyPriority( - endpointId: string, - priorities: Array<{ key_id: string; internal_priority: number }> -): Promise<{ message: string; updated_count: number }> { - const response = await client.put(`/api/admin/endpoints/${endpointId}/keys/batch-priority`, { - priorities - }) +export async function getProviderKeys(providerId: string): Promise { + const response = await client.get(`/api/admin/endpoints/providers/${providerId}/keys`) + return response.data +} + +/** + * 为 Provider 添加 Key + */ +export async function addProviderKey( + providerId: string, + data: { + api_formats: string[] // 支持的 API 格式列表(必填) + api_key: string + name: string + rate_multiplier?: number // 默认成本倍率 + rate_multipliers?: Record | null // 按 API 格式的成本倍率 + internal_priority?: number + rpm_limit?: number | null // RPM 限制(留空=自适应模式) + cache_ttl_minutes?: number + max_probe_interval_minutes?: number + allowed_models?: AllowedModels + capabilities?: Record + note?: string + } +): Promise { + const response = await client.post(`/api/admin/endpoints/providers/${providerId}/keys`, data) + return response.data +} + +/** + * 更新 Key + */ +export async function updateProviderKey( + keyId: string, + data: Partial<{ + api_formats: string[] // 支持的 API 格式列表 + api_key: string + name: string + rate_multiplier: number // 默认成本倍率 + rate_multipliers: Record | null // 按 API 格式的成本倍率 + internal_priority: number + global_priority: number | null + rpm_limit: number | null // RPM 限制(留空=自适应模式) + cache_ttl_minutes: number + max_probe_interval_minutes: number + allowed_models: AllowedModels + capabilities: Record | null + is_active: boolean + note: string + }> +): Promise { + const response = await client.put(`/api/admin/endpoints/keys/${keyId}`, data) return response.data } diff --git a/frontend/src/api/endpoints/models.ts b/frontend/src/api/endpoints/models.ts index 3620ac2..06b7560 100644 --- a/frontend/src/api/endpoints/models.ts +++ b/frontend/src/api/endpoints/models.ts @@ -147,14 +147,26 @@ export async function queryProviderUpstreamModels( /** * 从上游提供商导入模型 + * @param providerId 提供商 ID + * @param modelIds 模型 ID 列表 + * @param options 可选配置 + * @param options.tiered_pricing 阶梯计费配置 + * @param options.price_per_request 按次计费价格 */ export async function importModelsFromUpstream( providerId: string, - modelIds: string[] + modelIds: string[], + options?: { + tiered_pricing?: object + price_per_request?: number + } ): Promise { const response = await client.post( `/api/admin/providers/${providerId}/import-from-upstream`, - { model_ids: modelIds } + { + model_ids: modelIds, + ...options + } ) return response.data } diff --git a/frontend/src/api/endpoints/providers.ts b/frontend/src/api/endpoints/providers.ts index 960f833..d4ebfe9 100644 --- a/frontend/src/api/endpoints/providers.ts +++ b/frontend/src/api/endpoints/providers.ts @@ -1,5 +1,5 @@ import client from '../client' -import type { ProviderWithEndpointsSummary } from './types' +import type { ProviderWithEndpointsSummary, ProxyConfig } from './types' /** * 获取 Providers 摘要(包含 Endpoints 统计) @@ -23,7 +23,7 @@ export async function getProvider(providerId: string): Promise0表示支持缓存并设置TTL(分钟) max_probe_interval_minutes: number is_active: boolean @@ -83,7 +87,6 @@ export interface TestModelResponse { provider?: { id: string name: string - display_name: string } model?: string } @@ -92,4 +95,3 @@ export async function testModel(data: TestModelRequest): Promise = { [API_FORMATS.GEMINI_CLI]: 'Gemini CLI', } +// API 格式缩写映射(用于空间紧凑的显示场景) +export const API_FORMAT_SHORT: Record = { + [API_FORMATS.OPENAI]: 'O', + [API_FORMATS.OPENAI_CLI]: 'OC', + [API_FORMATS.CLAUDE]: 'C', + [API_FORMATS.CLAUDE_CLI]: 'CC', + [API_FORMATS.GEMINI]: 'G', + [API_FORMATS.GEMINI_CLI]: 'GC', +} + +// API 格式排序顺序(统一的显示顺序) +export const API_FORMAT_ORDER: string[] = [ + API_FORMATS.OPENAI, + API_FORMATS.OPENAI_CLI, + API_FORMATS.CLAUDE, + API_FORMATS.CLAUDE_CLI, + API_FORMATS.GEMINI, + API_FORMATS.GEMINI_CLI, +] + +// 工具函数:按标准顺序排序 API 格式数组 +export function sortApiFormats(formats: string[]): string[] { + return [...formats].sort((a, b) => { + const aIdx = API_FORMAT_ORDER.indexOf(a) + const bIdx = API_FORMAT_ORDER.indexOf(b) + if (aIdx === -1 && bIdx === -1) return 0 + if (aIdx === -1) return 1 + if (bIdx === -1) return -1 + return aIdx - bIdx + }) +} + /** * 代理配置类型 */ @@ -37,18 +69,9 @@ export interface ProviderEndpoint { api_format: string base_url: string custom_path?: string // 自定义请求路径(可选,为空则使用 API 格式默认路径) - auth_type: string - auth_header?: string headers?: Record timeout: number max_retries: number - priority: number - weight: number - max_concurrent?: number - rate_limit?: number - health_score: number - consecutive_failures: number - last_failure_at?: string is_active: boolean config?: Record proxy?: ProxyConfig | null @@ -58,25 +81,55 @@ export interface ProviderEndpoint { updated_at: string } +/** + * 模型权限配置类型(支持简单列表和按格式字典两种模式) + * + * 使用示例: + * 1. 不限制(允许所有模型): null + * 2. 简单列表模式(所有 API 格式共享同一个白名单): ["gpt-4", "claude-3-opus"] + * 3. 按格式字典模式(不同 API 格式使用不同的白名单): + * { "OPENAI": ["gpt-4"], "CLAUDE": ["claude-3-opus"] } + */ +export type AllowedModels = string[] | Record | null + +// AllowedModels 类型守卫函数 +export function isAllowedModelsList(value: AllowedModels): value is string[] { + return Array.isArray(value) +} + +export function isAllowedModelsDict(value: AllowedModels): value is Record { + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + return false + } + // 验证所有值都是字符串数组 + return Object.values(value).every( + (v) => Array.isArray(v) && v.every((item) => typeof item === 'string') + ) +} + export interface EndpointAPIKey { id: string - endpoint_id: string + provider_id: string + api_formats: string[] // 支持的 API 格式列表 api_key_masked: string api_key_plain?: string | null name: string // 密钥名称(必填,用于识别) - rate_multiplier: number // 成本倍率(真实成本 = 表面成本 × 倍率) - internal_priority: number // Endpoint 内部优先级 + rate_multiplier: number // 默认成本倍率(真实成本 = 表面成本 × 倍率) + rate_multipliers?: Record | null // 按 API 格式的成本倍率,如 {"CLAUDE": 1.0, "OPENAI": 0.8} + internal_priority: number // Key 内部优先级 global_priority?: number | null // 全局 Key 优先级 - max_concurrent?: number - rate_limit?: number - daily_limit?: number - monthly_limit?: number - allowed_models?: string[] | null // 允许使用的模型列表(null = 支持所有模型) + rpm_limit?: number | null // RPM 速率限制 (1-10000),null 表示自适应模式 + allowed_models?: AllowedModels // 允许使用的模型列表(null=不限制,列表=简单白名单,字典=按格式区分) capabilities?: Record | null // 能力标签配置(如 cache_1h, context_1m) // 缓存与熔断配置 cache_ttl_minutes: number // 缓存 TTL(分钟),0=禁用 max_probe_interval_minutes: number // 熔断探测间隔(分钟) + // 按格式的健康度数据 + health_by_format?: Record + circuit_breaker_by_format?: Record + // 聚合字段(从 health_by_format 计算,用于列表显示) health_score: number + circuit_breaker_open?: boolean consecutive_failures: number last_failure_at?: string request_count: number @@ -89,10 +142,10 @@ export interface EndpointAPIKey { last_used_at?: string created_at: string updated_at: string - // 自适应并发字段 - is_adaptive?: boolean // 是否为自适应模式(max_concurrent=NULL) - effective_limit?: number // 当前有效限制(自适应使用学习值,固定使用配置值) - learned_max_concurrent?: number + // 自适应 RPM 字段 + is_adaptive?: boolean // 是否为自适应模式(rpm_limit=NULL) + effective_limit?: number // 当前有效 RPM 限制(自适应使用学习值,固定使用配置值) + learned_rpm_limit?: number // 学习到的 RPM 限制 // 滑动窗口利用率采样 utilization_samples?: Array<{ ts: number; util: number }> // 利用率采样窗口 last_probe_increase_at?: string // 上次探测性扩容时间 @@ -100,8 +153,7 @@ export interface EndpointAPIKey { rpm_429_count?: number last_429_at?: string last_429_type?: string - // 熔断器字段(滑动窗口 + 半开模式) - circuit_breaker_open?: boolean + // 单格式场景的熔断器字段 circuit_breaker_open_at?: string next_probe_at?: string half_open_until?: string @@ -110,17 +162,36 @@ export interface EndpointAPIKey { request_results_window?: Array<{ ts: number; ok: boolean }> // 请求结果滑动窗口 } +// 按格式的健康度数据 +export interface FormatHealthData { + health_score: number + error_rate: number + window_size: number + consecutive_failures: number + last_failure_at?: string | null + circuit_breaker: FormatCircuitBreakerData +} + +// 按格式的熔断器数据 +export interface FormatCircuitBreakerData { + open: boolean + open_at?: string | null + next_probe_at?: string | null + half_open_until?: string | null + half_open_successes: number + half_open_failures: number +} + export interface EndpointAPIKeyUpdate { + api_formats?: string[] // 支持的 API 格式列表 name?: string api_key?: string // 仅在需要更新时提供 - rate_multiplier?: number + rate_multiplier?: number // 默认成本倍率 + rate_multipliers?: Record | null // 按 API 格式的成本倍率 internal_priority?: number global_priority?: number | null - max_concurrent?: number | null // null 表示切换为自适应模式 - rate_limit?: number - daily_limit?: number - monthly_limit?: number - allowed_models?: string[] | null + rpm_limit?: number | null // RPM 速率限制 (1-10000),null 表示切换为自适应模式 + allowed_models?: AllowedModels capabilities?: Record | null cache_ttl_minutes?: number max_probe_interval_minutes?: number @@ -198,7 +269,6 @@ export interface PublicEndpointStatusMonitorResponse { export interface ProviderWithEndpointsSummary { id: string name: string - display_name: string description?: string website?: string provider_priority: number @@ -208,9 +278,10 @@ export interface ProviderWithEndpointsSummary { quota_reset_day?: number quota_last_reset_at?: string // 当前周期开始时间 quota_expires_at?: string - rpm_limit?: number | null - rpm_used?: number - rpm_reset_at?: string + // 请求配置(从 Endpoint 迁移) + timeout?: number // 请求超时(秒) + max_retries?: number // 最大重试次数 + proxy?: ProxyConfig | null // 代理配置 is_active: boolean total_endpoints: number active_endpoints: number @@ -253,13 +324,10 @@ export interface HealthSummary { } } -export interface ConcurrencyStatus { - endpoint_id?: string - endpoint_current_concurrency: number - endpoint_max_concurrent?: number - key_id?: string - key_current_concurrency: number - key_max_concurrent?: number +export interface KeyRpmStatus { + key_id: string + current_rpm: number + rpm_limit?: number } export interface ProviderModelMapping { @@ -361,7 +429,6 @@ export interface ModelPriceRange { export interface ModelCatalogProviderDetail { provider_id: string provider_name: string - provider_display_name?: string | null model_id?: string | null target_model: string input_price_per_1m?: number | null @@ -534,10 +601,10 @@ export interface UpstreamModel { */ export interface ImportFromUpstreamSuccessItem { model_id: string - global_model_id: string - global_model_name: string provider_model_id: string - created_global_model: boolean + global_model_id?: string // 可选,未关联时为空字符串 + global_model_name?: string // 可选,未关联时为空字符串 + created_global_model: boolean // 始终为 false(不再自动创建 GlobalModel) } /** diff --git a/frontend/src/components/ui/collapsible-content.vue b/frontend/src/components/ui/collapsible-content.vue new file mode 100644 index 0000000..1a2dabd --- /dev/null +++ b/frontend/src/components/ui/collapsible-content.vue @@ -0,0 +1,15 @@ + + + diff --git a/frontend/src/components/ui/collapsible-trigger.vue b/frontend/src/components/ui/collapsible-trigger.vue new file mode 100644 index 0000000..b12d852 --- /dev/null +++ b/frontend/src/components/ui/collapsible-trigger.vue @@ -0,0 +1,11 @@ + + + diff --git a/frontend/src/components/ui/collapsible.vue b/frontend/src/components/ui/collapsible.vue new file mode 100644 index 0000000..ce204cf --- /dev/null +++ b/frontend/src/components/ui/collapsible.vue @@ -0,0 +1,15 @@ + + + diff --git a/frontend/src/components/ui/index.ts b/frontend/src/components/ui/index.ts index fb3f3a0..5105ebe 100644 --- a/frontend/src/components/ui/index.ts +++ b/frontend/src/components/ui/index.ts @@ -65,3 +65,8 @@ export { default as RefreshButton } from './refresh-button.vue' // Tooltip 提示系列 export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './tooltip' + +// Collapsible 折叠系列 +export { default as Collapsible } from './collapsible.vue' +export { default as CollapsibleTrigger } from './collapsible-trigger.vue' +export { default as CollapsibleContent } from './collapsible-content.vue' diff --git a/frontend/src/features/api-keys/components/StandaloneKeyFormDialog.vue b/frontend/src/features/api-keys/components/StandaloneKeyFormDialog.vue index 6270036..474f716 100644 --- a/frontend/src/features/api-keys/components/StandaloneKeyFormDialog.vue +++ b/frontend/src/features/api-keys/components/StandaloneKeyFormDialog.vue @@ -186,7 +186,7 @@ @click.stop @change="toggleSelection('allowed_providers', provider.id)" > - {{ provider.display_name || provider.name }} + {{ provider.name }}
- {{ provider.display_name }} + {{ provider.name }}
@@ -595,7 +595,7 @@ class="w-2 h-2 rounded-full shrink-0" :class="provider.is_active ? 'bg-green-500' : 'bg-gray-300'" /> - {{ provider.display_name }} + {{ provider.name }}
+ + +
+ + - -
-
-

- 代理配置 -

-
- - 启用代理 -
-
- -
-
- - -

+

+ +
+
+ +
- -
-
- - -
- -
- - -
+
+ +
+
+ + +
+
- + + +
+

所有 API 格式都已配置

+
+
- - - diff --git a/frontend/src/features/providers/components/KeyAllowedModelsDialog.vue b/frontend/src/features/providers/components/KeyAllowedModelsDialog.vue index 2dc880a..39878ba 100644 --- a/frontend/src/features/providers/components/KeyAllowedModelsDialog.vue +++ b/frontend/src/features/providers/components/KeyAllowedModelsDialog.vue @@ -1,165 +1,179 @@