mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-01 17:00:21 +08:00
refactor: 移除 Python 后端源码,全面迁移至 Rust gateway 架构
- 删除全部 Python 源码 (src/) 及 Alembic 迁移脚本,归档至 _deprecated_py_src/ - 重构 Rust gateway ai_pipeline: 拆分 planner/finalize 模块,新增 contracts/adaptation 层 - 重组 handlers 模块为 admin/public/proxy/internal/shared 子模块结构 - 新增 executor 模块,引入 Rust 原生数据库迁移 (aether-data/migrations) - 简化 CI/Docker 构建流程,移除 base image 二级构建,统一为单一 app image - 移除 Python 相关基础设施文件 (entrypoint.sh, gunicorn_conf.py, Dockerfile.base)
This commit is contained in:
14
_deprecated_py_src/__init__.py
Normal file
14
_deprecated_py_src/__init__.py
Normal file
@@ -0,0 +1,14 @@
|
||||
"""AI Proxy
|
||||
|
||||
A proxy server that enables AI models to work with multiple API providers.
|
||||
"""
|
||||
|
||||
# 注意: dotenv 加载已统一移至 src/config/settings.py
|
||||
# 不要在此处重复加载
|
||||
|
||||
try:
|
||||
from src._version import __version__
|
||||
except ImportError:
|
||||
__version__ = "0.0.0.dev0"
|
||||
|
||||
__author__ = "AI Proxy"
|
||||
24
_deprecated_py_src/_version.py
Normal file
24
_deprecated_py_src/_version.py
Normal file
@@ -0,0 +1,24 @@
|
||||
# file generated by vcs-versioning
|
||||
# don't change, don't track in version control
|
||||
from __future__ import annotations
|
||||
|
||||
__all__ = [
|
||||
"__version__",
|
||||
"__version_tuple__",
|
||||
"version",
|
||||
"version_tuple",
|
||||
"__commit_id__",
|
||||
"commit_id",
|
||||
]
|
||||
|
||||
version: str
|
||||
__version__: str
|
||||
__version_tuple__: tuple[int | str, ...]
|
||||
version_tuple: tuple[int | str, ...]
|
||||
commit_id: str | None
|
||||
__commit_id__: str | None
|
||||
|
||||
__version__ = version = '0.6.4.dev6+gddf18fed9.d20260331'
|
||||
__version_tuple__ = version_tuple = (0, 6, 4, 'dev6', 'gddf18fed9.d20260331')
|
||||
|
||||
__commit_id__ = commit_id = None
|
||||
51
_deprecated_py_src/alembic.ini
Normal file
51
_deprecated_py_src/alembic.ini
Normal file
@@ -0,0 +1,51 @@
|
||||
# Alembic 配置文件
|
||||
# 用于数据库版本化迁移
|
||||
|
||||
[alembic]
|
||||
# 迁移脚本存放目录
|
||||
script_location = alembic
|
||||
|
||||
# 模板文件
|
||||
file_template = %%(year)d%%(month).2d%%(day).2d_%%(hour).2d%%(minute).2d_%%(rev)s_%%(slug)s
|
||||
|
||||
# 时区(用于生成迁移文件的时间戳)
|
||||
timezone = UTC
|
||||
|
||||
# 数据库连接 URL(会被 env.py 从环境变量覆盖)
|
||||
# Docker 环境中会从 DATABASE_URL 环境变量读取
|
||||
sqlalchemy.url = postgresql://postgres:${DB_PASSWORD}@localhost:5432/aether
|
||||
|
||||
# 日志配置
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARN
|
||||
handlers = console
|
||||
qualname =
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARN
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = alembic
|
||||
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
formatter = generic
|
||||
|
||||
[formatter_generic]
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
datefmt = %H:%M:%S
|
||||
121
_deprecated_py_src/alembic/env.py
Normal file
121
_deprecated_py_src/alembic/env.py
Normal file
@@ -0,0 +1,121 @@
|
||||
"""
|
||||
Alembic 环境配置
|
||||
用于数据库迁移的运行时环境设置
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
from logging.config import fileConfig
|
||||
from pathlib import Path
|
||||
|
||||
from sqlalchemy import engine_from_config, pool, text
|
||||
|
||||
from alembic import context
|
||||
|
||||
# 添加项目根目录到 Python 路径
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
|
||||
|
||||
# 加载 .env 文件(本地开发时需要)
|
||||
try:
|
||||
from dotenv import load_dotenv
|
||||
|
||||
env_file = Path(__file__).parent.parent / ".env"
|
||||
if env_file.exists():
|
||||
load_dotenv(env_file)
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
# 导入所有数据库模型(确保 Alembic 能检测到所有表)
|
||||
from src.models.database import Base
|
||||
|
||||
# Alembic Config 对象
|
||||
config = context.config
|
||||
|
||||
# 从环境变量获取数据库 URL
|
||||
# 优先使用 DATABASE_URL,否则从 DB_PASSWORD 自动构建(与 docker compose 保持一致)
|
||||
database_url = os.getenv("DATABASE_URL")
|
||||
if not database_url:
|
||||
db_password = os.getenv("DB_PASSWORD", "")
|
||||
db_host = os.getenv("DB_HOST", "localhost")
|
||||
db_port = os.getenv("DB_PORT", "5432")
|
||||
db_name = os.getenv("DB_NAME", "aether")
|
||||
db_user = os.getenv("DB_USER", "postgres")
|
||||
database_url = f"postgresql://{db_user}:{db_password}@{db_host}:{db_port}/{db_name}"
|
||||
config.set_main_option("sqlalchemy.url", database_url)
|
||||
|
||||
# 配置日志
|
||||
if config.config_file_name is not None:
|
||||
fileConfig(config.config_file_name)
|
||||
|
||||
# 目标元数据(包含所有表定义)
|
||||
target_metadata = Base.metadata
|
||||
|
||||
# PostgreSQL 全局迁移锁,避免多进程并发执行 Alembic 导致竞态(重复加列/索引等)
|
||||
# 使用会话级 advisory lock(pg_advisory_lock),在迁移完成后手动释放。
|
||||
# ID 由 crc32("aether-alembic-migration") 拼接生成,仅需全局唯一即可。
|
||||
MIGRATION_ADVISORY_LOCK_ID = 582694137405821
|
||||
|
||||
|
||||
def run_migrations_offline() -> None:
|
||||
"""
|
||||
离线模式运行迁移
|
||||
|
||||
在离线模式下,不需要连接数据库,
|
||||
只生成 SQL 脚本
|
||||
"""
|
||||
url = config.get_main_option("sqlalchemy.url")
|
||||
context.configure(
|
||||
url=url,
|
||||
target_metadata=target_metadata,
|
||||
literal_binds=True,
|
||||
dialect_opts={"paramstyle": "named"},
|
||||
compare_type=True, # 比较列类型变更
|
||||
compare_server_default=True, # 比较默认值变更
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def run_migrations_online() -> None:
|
||||
"""
|
||||
在线模式运行迁移
|
||||
|
||||
在线模式下,直接连接数据库执行迁移
|
||||
"""
|
||||
connectable = engine_from_config(
|
||||
config.get_section(config.config_ini_section, {}),
|
||||
prefix="sqlalchemy.",
|
||||
poolclass=pool.NullPool,
|
||||
)
|
||||
|
||||
with connectable.connect() as connection:
|
||||
try:
|
||||
# 使用会话级 advisory lock(非事务级),避免干扰 Alembic 的事务管理。
|
||||
# pg_advisory_lock 在会话结束时自动释放,不受 COMMIT/ROLLBACK 影响。
|
||||
if connection.dialect.name == "postgresql":
|
||||
connection.execute(
|
||||
text("SELECT pg_advisory_lock(:lock_id)"),
|
||||
{"lock_id": MIGRATION_ADVISORY_LOCK_ID},
|
||||
)
|
||||
connection.commit()
|
||||
|
||||
context.configure(
|
||||
connection=connection,
|
||||
target_metadata=target_metadata,
|
||||
compare_type=True,
|
||||
compare_server_default=True,
|
||||
transaction_per_migration=True, # 每个迁移文件独立事务,完成即提交
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
except Exception:
|
||||
raise
|
||||
|
||||
|
||||
# 根据模式选择运行方式
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
||||
26
_deprecated_py_src/alembic/script.py.mako
Normal file
26
_deprecated_py_src/alembic/script.py.mako
Normal file
@@ -0,0 +1,26 @@
|
||||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
${imports if imports else ""}
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = ${repr(up_revision)}
|
||||
down_revision = ${repr(down_revision)}
|
||||
branch_labels = ${repr(branch_labels)}
|
||||
depends_on = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""应用迁移:升级到新版本"""
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""回滚迁移:降级到旧版本"""
|
||||
${downgrades if downgrades else "pass"}
|
||||
775
_deprecated_py_src/alembic/versions/20251210_baseline.py
Normal file
775
_deprecated_py_src/alembic/versions/20251210_baseline.py
Normal file
@@ -0,0 +1,775 @@
|
||||
"""Baseline migration - all tables consolidated
|
||||
|
||||
Revision ID: 20251210_baseline
|
||||
Revises:
|
||||
Create Date: 2024-12-10
|
||||
|
||||
This is the consolidated baseline migration that creates all tables from scratch.
|
||||
Includes all schema changes up to circuit breaker v2.
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
# revision identifiers
|
||||
revision = "20251210_baseline"
|
||||
down_revision = None
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Create ENUM types (with IF NOT EXISTS for idempotency)
|
||||
op.execute("DO $$ BEGIN CREATE TYPE userrole AS ENUM ('admin', 'user'); EXCEPTION WHEN duplicate_object THEN NULL; END $$")
|
||||
op.execute(
|
||||
"DO $$ BEGIN CREATE TYPE providerbillingtype AS ENUM ('monthly_quota', 'pay_as_you_go', 'free_tier'); EXCEPTION WHEN duplicate_object THEN NULL; END $$"
|
||||
)
|
||||
|
||||
# ==================== users ====================
|
||||
op.create_table(
|
||||
"users",
|
||||
sa.Column("id", sa.String(36), primary_key=True, index=True),
|
||||
sa.Column("email", sa.String(255), unique=True, index=True, nullable=False),
|
||||
sa.Column("username", sa.String(100), unique=True, index=True, nullable=False),
|
||||
sa.Column("password_hash", sa.String(255), nullable=False),
|
||||
sa.Column(
|
||||
"role",
|
||||
postgresql.ENUM("admin", "user", name="userrole", create_type=False),
|
||||
nullable=False,
|
||||
server_default="user",
|
||||
),
|
||||
sa.Column("allowed_providers", sa.JSON, nullable=True),
|
||||
sa.Column("allowed_endpoints", sa.JSON, nullable=True),
|
||||
sa.Column("allowed_models", sa.JSON, nullable=True),
|
||||
sa.Column("model_capability_settings", sa.JSON, nullable=True),
|
||||
sa.Column("quota_usd", sa.Float, nullable=True),
|
||||
sa.Column("used_usd", sa.Float, server_default="0.0"),
|
||||
sa.Column("total_usd", sa.Float, server_default="0.0"),
|
||||
sa.Column("is_active", sa.Boolean, server_default="true", nullable=False),
|
||||
sa.Column("is_deleted", sa.Boolean, server_default="false", nullable=False),
|
||||
sa.Column(
|
||||
"created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||
),
|
||||
sa.Column("last_login_at", sa.DateTime(timezone=True), nullable=True),
|
||||
)
|
||||
|
||||
# ==================== providers ====================
|
||||
op.create_table(
|
||||
"providers",
|
||||
sa.Column("id", sa.String(36), primary_key=True, index=True),
|
||||
sa.Column("name", sa.String(100), unique=True, index=True, nullable=False),
|
||||
sa.Column("display_name", sa.String(100), nullable=False),
|
||||
sa.Column("description", sa.Text, nullable=True),
|
||||
sa.Column("website", sa.String(500), nullable=True),
|
||||
sa.Column(
|
||||
"billing_type",
|
||||
postgresql.ENUM(
|
||||
"monthly_quota", "pay_as_you_go", "free_tier", name="providerbillingtype", create_type=False
|
||||
),
|
||||
nullable=False,
|
||||
server_default="pay_as_you_go",
|
||||
),
|
||||
sa.Column("monthly_quota_usd", sa.Float, nullable=True),
|
||||
sa.Column("monthly_used_usd", sa.Float, server_default="0.0"),
|
||||
sa.Column("quota_reset_day", sa.Integer, server_default="30"),
|
||||
sa.Column("quota_last_reset_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("quota_expires_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("rpm_limit", sa.Integer, nullable=True),
|
||||
sa.Column("rpm_used", sa.Integer, server_default="0"),
|
||||
sa.Column("rpm_reset_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("provider_priority", sa.Integer, server_default="100"),
|
||||
sa.Column("is_active", sa.Boolean, server_default="true", nullable=False),
|
||||
sa.Column("rate_limit", sa.Integer, nullable=True),
|
||||
sa.Column("concurrent_limit", sa.Integer, nullable=True),
|
||||
sa.Column("config", sa.JSON, nullable=True),
|
||||
sa.Column(
|
||||
"created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||
),
|
||||
)
|
||||
|
||||
# ==================== global_models ====================
|
||||
op.create_table(
|
||||
"global_models",
|
||||
sa.Column("id", sa.String(36), primary_key=True, index=True),
|
||||
sa.Column("name", sa.String(100), unique=True, index=True, nullable=False),
|
||||
sa.Column("display_name", sa.String(100), nullable=False),
|
||||
sa.Column("description", sa.Text, nullable=True),
|
||||
sa.Column("icon_url", sa.String(500), nullable=True),
|
||||
sa.Column("official_url", sa.String(500), nullable=True),
|
||||
sa.Column("default_price_per_request", sa.Float, nullable=True),
|
||||
sa.Column("default_tiered_pricing", sa.JSON, nullable=False),
|
||||
sa.Column("default_supports_vision", sa.Boolean, server_default="false", nullable=True),
|
||||
sa.Column("default_supports_function_calling", sa.Boolean, server_default="false", nullable=True),
|
||||
sa.Column("default_supports_streaming", sa.Boolean, server_default="true", nullable=True),
|
||||
sa.Column("default_supports_extended_thinking", sa.Boolean, server_default="false", nullable=True),
|
||||
sa.Column("default_supports_image_generation", sa.Boolean, server_default="false", nullable=True),
|
||||
sa.Column("supported_capabilities", sa.JSON, nullable=True),
|
||||
sa.Column("is_active", sa.Boolean, server_default="true", nullable=False),
|
||||
sa.Column("usage_count", sa.Integer, server_default="0", nullable=False, index=True),
|
||||
sa.Column(
|
||||
"created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||
),
|
||||
)
|
||||
|
||||
# ==================== api_keys ====================
|
||||
op.create_table(
|
||||
"api_keys",
|
||||
sa.Column("id", sa.String(36), primary_key=True, index=True),
|
||||
sa.Column(
|
||||
"user_id", sa.String(36), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False
|
||||
),
|
||||
sa.Column("key_hash", sa.String(64), unique=True, index=True, nullable=False),
|
||||
sa.Column("key_encrypted", sa.Text, nullable=True),
|
||||
sa.Column("name", sa.String(100), nullable=True),
|
||||
sa.Column("total_requests", sa.Integer, server_default="0"),
|
||||
sa.Column("total_cost_usd", sa.Float, server_default="0.0"),
|
||||
sa.Column("balance_used_usd", sa.Float, server_default="0.0"),
|
||||
sa.Column("current_balance_usd", sa.Float, nullable=True),
|
||||
sa.Column("is_standalone", sa.Boolean, server_default="false", nullable=False),
|
||||
sa.Column("allowed_providers", sa.JSON, nullable=True),
|
||||
sa.Column("allowed_endpoints", sa.JSON, nullable=True),
|
||||
sa.Column("allowed_api_formats", sa.JSON, nullable=True),
|
||||
sa.Column("allowed_models", sa.JSON, nullable=True),
|
||||
sa.Column("rate_limit", sa.Integer, server_default="100"),
|
||||
sa.Column("concurrent_limit", sa.Integer, server_default="5", nullable=True),
|
||||
sa.Column("force_capabilities", sa.JSON, nullable=True),
|
||||
sa.Column("is_active", sa.Boolean, server_default="true", nullable=False),
|
||||
sa.Column("last_used_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("auto_delete_on_expiry", sa.Boolean, server_default="false", nullable=False),
|
||||
sa.Column(
|
||||
"created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||
),
|
||||
)
|
||||
|
||||
# ==================== provider_endpoints ====================
|
||||
op.create_table(
|
||||
"provider_endpoints",
|
||||
sa.Column("id", sa.String(36), primary_key=True, index=True),
|
||||
sa.Column(
|
||||
"provider_id",
|
||||
sa.String(36),
|
||||
sa.ForeignKey("providers.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("api_format", sa.String(50), nullable=False),
|
||||
sa.Column("base_url", sa.String(500), nullable=False),
|
||||
sa.Column("headers", sa.JSON, nullable=True),
|
||||
sa.Column("timeout", sa.Integer, server_default="300"),
|
||||
sa.Column("max_retries", sa.Integer, server_default="3"),
|
||||
sa.Column("max_concurrent", sa.Integer, nullable=True),
|
||||
sa.Column("rate_limit", sa.Integer, nullable=True),
|
||||
sa.Column("is_active", sa.Boolean, server_default="true", nullable=False),
|
||||
sa.Column("custom_path", sa.String(200), nullable=True),
|
||||
sa.Column("config", sa.JSON, nullable=True),
|
||||
sa.Column(
|
||||
"created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||
),
|
||||
sa.UniqueConstraint("provider_id", "api_format", name="uq_provider_api_format"),
|
||||
)
|
||||
op.create_index(
|
||||
"idx_endpoint_format_active", "provider_endpoints", ["api_format", "is_active"]
|
||||
)
|
||||
|
||||
# ==================== models ====================
|
||||
op.create_table(
|
||||
"models",
|
||||
sa.Column("id", sa.String(36), primary_key=True, index=True),
|
||||
sa.Column(
|
||||
"provider_id", sa.String(36), sa.ForeignKey("providers.id"), nullable=False
|
||||
),
|
||||
sa.Column(
|
||||
"global_model_id",
|
||||
sa.String(36),
|
||||
sa.ForeignKey("global_models.id"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
),
|
||||
sa.Column("provider_model_name", sa.String(200), nullable=False),
|
||||
sa.Column("price_per_request", sa.Float, nullable=True),
|
||||
sa.Column("tiered_pricing", sa.JSON, nullable=True),
|
||||
sa.Column("supports_vision", sa.Boolean, nullable=True),
|
||||
sa.Column("supports_function_calling", sa.Boolean, nullable=True),
|
||||
sa.Column("supports_streaming", sa.Boolean, nullable=True),
|
||||
sa.Column("supports_extended_thinking", sa.Boolean, nullable=True),
|
||||
sa.Column("supports_image_generation", sa.Boolean, nullable=True),
|
||||
sa.Column("is_active", sa.Boolean, server_default="true", nullable=False),
|
||||
sa.Column("is_available", sa.Boolean, server_default="true"),
|
||||
sa.Column("config", sa.JSON, nullable=True),
|
||||
sa.Column(
|
||||
"created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||
),
|
||||
sa.UniqueConstraint("provider_id", "provider_model_name", name="uq_provider_model"),
|
||||
)
|
||||
|
||||
# ==================== model_mappings ====================
|
||||
op.create_table(
|
||||
"model_mappings",
|
||||
sa.Column("id", sa.String(36), primary_key=True, index=True),
|
||||
sa.Column("source_model", sa.String(200), nullable=False, index=True),
|
||||
sa.Column(
|
||||
"target_global_model_id",
|
||||
sa.String(36),
|
||||
sa.ForeignKey("global_models.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
),
|
||||
sa.Column(
|
||||
"provider_id", sa.String(36), sa.ForeignKey("providers.id"), nullable=True, index=True
|
||||
),
|
||||
sa.Column("mapping_type", sa.String(20), nullable=False, server_default="alias", index=True),
|
||||
sa.Column("is_active", sa.Boolean, server_default="true", nullable=False),
|
||||
sa.Column(
|
||||
"created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||
),
|
||||
sa.UniqueConstraint("source_model", "provider_id", name="uq_model_mapping_source_provider"),
|
||||
)
|
||||
|
||||
# ==================== provider_api_keys ====================
|
||||
op.create_table(
|
||||
"provider_api_keys",
|
||||
sa.Column("id", sa.String(36), primary_key=True, index=True),
|
||||
sa.Column(
|
||||
"endpoint_id",
|
||||
sa.String(36),
|
||||
sa.ForeignKey("provider_endpoints.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("api_key", sa.String(500), nullable=False),
|
||||
sa.Column("name", sa.String(100), nullable=False),
|
||||
sa.Column("note", sa.String(500), nullable=True),
|
||||
sa.Column("rate_multiplier", sa.Float, server_default="1.0", nullable=False),
|
||||
sa.Column("internal_priority", sa.Integer, server_default="50"),
|
||||
sa.Column("global_priority", sa.Integer, nullable=True),
|
||||
sa.Column("max_concurrent", sa.Integer, nullable=True),
|
||||
sa.Column("rate_limit", sa.Integer, nullable=True),
|
||||
sa.Column("daily_limit", sa.Integer, nullable=True),
|
||||
sa.Column("monthly_limit", sa.Integer, nullable=True),
|
||||
sa.Column("allowed_models", sa.JSON, nullable=True),
|
||||
sa.Column("capabilities", sa.JSON, nullable=True),
|
||||
sa.Column("learned_max_concurrent", sa.Integer, nullable=True),
|
||||
sa.Column("concurrent_429_count", sa.Integer, server_default="0", nullable=False),
|
||||
sa.Column("rpm_429_count", sa.Integer, server_default="0", nullable=False),
|
||||
sa.Column("last_429_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("last_429_type", sa.String(50), nullable=True),
|
||||
sa.Column("last_concurrent_peak", sa.Integer, nullable=True),
|
||||
sa.Column("adjustment_history", sa.JSON, nullable=True),
|
||||
# Sliding window fields (replaces high_utilization_start)
|
||||
sa.Column("utilization_samples", sa.JSON, nullable=True),
|
||||
sa.Column("last_probe_increase_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("health_score", sa.Float, server_default="1.0"),
|
||||
sa.Column("consecutive_failures", sa.Integer, server_default="0"),
|
||||
sa.Column("last_failure_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("cache_ttl_minutes", sa.Integer, server_default="5", nullable=False),
|
||||
sa.Column("max_probe_interval_minutes", sa.Integer, server_default="32", nullable=False),
|
||||
sa.Column("circuit_breaker_open", sa.Boolean, server_default="false", nullable=False),
|
||||
sa.Column("circuit_breaker_open_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("next_probe_at", sa.DateTime(timezone=True), nullable=True),
|
||||
# Circuit breaker v2 fields
|
||||
sa.Column("request_results_window", sa.JSON, nullable=True),
|
||||
sa.Column("half_open_until", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("half_open_successes", sa.Integer, server_default="0", nullable=True),
|
||||
sa.Column("half_open_failures", sa.Integer, server_default="0", nullable=True),
|
||||
sa.Column("request_count", sa.Integer, server_default="0"),
|
||||
sa.Column("success_count", sa.Integer, server_default="0"),
|
||||
sa.Column("error_count", sa.Integer, server_default="0"),
|
||||
sa.Column("total_response_time_ms", sa.Integer, server_default="0"),
|
||||
sa.Column("last_used_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("last_error_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("last_error_msg", sa.Text, nullable=True),
|
||||
sa.Column("is_active", sa.Boolean, server_default="true", nullable=False),
|
||||
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column(
|
||||
"created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||
),
|
||||
)
|
||||
|
||||
# ==================== usage ====================
|
||||
op.create_table(
|
||||
"usage",
|
||||
sa.Column("id", sa.String(36), primary_key=True, index=True),
|
||||
sa.Column(
|
||||
"user_id",
|
||||
sa.String(36),
|
||||
sa.ForeignKey("users.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
),
|
||||
sa.Column(
|
||||
"api_key_id",
|
||||
sa.String(36),
|
||||
sa.ForeignKey("api_keys.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
),
|
||||
sa.Column("request_id", sa.String(100), unique=True, index=True, nullable=False),
|
||||
sa.Column("provider", sa.String(100), nullable=False),
|
||||
sa.Column("model", sa.String(100), nullable=False),
|
||||
sa.Column("target_model", sa.String(100), nullable=True),
|
||||
sa.Column(
|
||||
"provider_id",
|
||||
sa.String(36),
|
||||
sa.ForeignKey("providers.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
),
|
||||
sa.Column(
|
||||
"provider_endpoint_id",
|
||||
sa.String(36),
|
||||
sa.ForeignKey("provider_endpoints.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
),
|
||||
sa.Column(
|
||||
"provider_api_key_id",
|
||||
sa.String(36),
|
||||
sa.ForeignKey("provider_api_keys.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
),
|
||||
sa.Column("input_tokens", sa.Integer, server_default="0"),
|
||||
sa.Column("output_tokens", sa.Integer, server_default="0"),
|
||||
sa.Column("total_tokens", sa.Integer, server_default="0"),
|
||||
sa.Column("cache_creation_input_tokens", sa.Integer, server_default="0"),
|
||||
sa.Column("cache_read_input_tokens", sa.Integer, server_default="0"),
|
||||
sa.Column("input_cost_usd", sa.Float, server_default="0.0"),
|
||||
sa.Column("output_cost_usd", sa.Float, server_default="0.0"),
|
||||
sa.Column("cache_cost_usd", sa.Float, server_default="0.0"),
|
||||
sa.Column("cache_creation_cost_usd", sa.Float, server_default="0.0"),
|
||||
sa.Column("cache_read_cost_usd", sa.Float, server_default="0.0"),
|
||||
sa.Column("request_cost_usd", sa.Float, server_default="0.0"),
|
||||
sa.Column("total_cost_usd", sa.Float, server_default="0.0"),
|
||||
sa.Column("actual_input_cost_usd", sa.Float, server_default="0.0"),
|
||||
sa.Column("actual_output_cost_usd", sa.Float, server_default="0.0"),
|
||||
sa.Column("actual_cache_creation_cost_usd", sa.Float, server_default="0.0"),
|
||||
sa.Column("actual_cache_read_cost_usd", sa.Float, server_default="0.0"),
|
||||
sa.Column("actual_request_cost_usd", sa.Float, server_default="0.0"),
|
||||
sa.Column("actual_total_cost_usd", sa.Float, server_default="0.0"),
|
||||
sa.Column("rate_multiplier", sa.Float, server_default="1.0"),
|
||||
sa.Column("input_price_per_1m", sa.Float, nullable=True),
|
||||
sa.Column("output_price_per_1m", sa.Float, nullable=True),
|
||||
sa.Column("cache_creation_price_per_1m", sa.Float, nullable=True),
|
||||
sa.Column("cache_read_price_per_1m", sa.Float, nullable=True),
|
||||
sa.Column("price_per_request", sa.Float, nullable=True),
|
||||
sa.Column("request_type", sa.String(50), nullable=True),
|
||||
sa.Column("api_format", sa.String(50), nullable=True),
|
||||
sa.Column("is_stream", sa.Boolean, server_default="false"),
|
||||
sa.Column("status_code", sa.Integer, nullable=True),
|
||||
sa.Column("error_message", sa.Text, nullable=True),
|
||||
sa.Column("response_time_ms", sa.Integer, nullable=True),
|
||||
sa.Column("status", sa.String(20), server_default="completed", nullable=False, index=True),
|
||||
sa.Column("request_headers", sa.JSON, nullable=True),
|
||||
sa.Column("request_body", sa.JSON, nullable=True),
|
||||
sa.Column("provider_request_headers", sa.JSON, nullable=True),
|
||||
sa.Column("response_headers", sa.JSON, nullable=True),
|
||||
sa.Column("response_body", sa.JSON, nullable=True),
|
||||
sa.Column("request_body_compressed", sa.LargeBinary, nullable=True),
|
||||
sa.Column("response_body_compressed", sa.LargeBinary, nullable=True),
|
||||
sa.Column("request_metadata", sa.JSON, nullable=True),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.func.now(),
|
||||
nullable=False,
|
||||
index=True,
|
||||
),
|
||||
)
|
||||
# usage 表复合索引(优化常见查询)
|
||||
op.create_index("idx_usage_user_created", "usage", ["user_id", "created_at"])
|
||||
op.create_index("idx_usage_apikey_created", "usage", ["api_key_id", "created_at"])
|
||||
op.create_index("idx_usage_provider_model_created", "usage", ["provider", "model", "created_at"])
|
||||
|
||||
# ==================== user_quotas ====================
|
||||
op.create_table(
|
||||
"user_quotas",
|
||||
sa.Column("id", sa.String(36), primary_key=True, index=True),
|
||||
sa.Column(
|
||||
"user_id", sa.String(36), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False
|
||||
),
|
||||
sa.Column("quota_type", sa.String(50), nullable=False),
|
||||
sa.Column("quota_usd", sa.Float, nullable=False),
|
||||
sa.Column("period_start", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("period_end", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("used_usd", sa.Float, server_default="0.0"),
|
||||
sa.Column("is_active", sa.Boolean, server_default="true"),
|
||||
sa.Column(
|
||||
"created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||
),
|
||||
)
|
||||
|
||||
# ==================== system_configs ====================
|
||||
op.create_table(
|
||||
"system_configs",
|
||||
sa.Column("id", sa.String(36), primary_key=True, index=True),
|
||||
sa.Column("key", sa.String(100), unique=True, nullable=False),
|
||||
sa.Column("value", sa.JSON, nullable=False),
|
||||
sa.Column("description", sa.Text, nullable=True),
|
||||
sa.Column(
|
||||
"created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||
),
|
||||
)
|
||||
|
||||
# ==================== user_preferences ====================
|
||||
op.create_table(
|
||||
"user_preferences",
|
||||
sa.Column("id", sa.String(36), primary_key=True, index=True),
|
||||
sa.Column(
|
||||
"user_id",
|
||||
sa.String(36),
|
||||
sa.ForeignKey("users.id", ondelete="CASCADE"),
|
||||
unique=True,
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("avatar_url", sa.String(500), nullable=True),
|
||||
sa.Column("bio", sa.Text, nullable=True),
|
||||
sa.Column(
|
||||
"default_provider_id", sa.String(36), sa.ForeignKey("providers.id"), nullable=True
|
||||
),
|
||||
sa.Column("theme", sa.String(20), server_default="light"),
|
||||
sa.Column("language", sa.String(10), server_default="zh-CN"),
|
||||
sa.Column("timezone", sa.String(50), server_default="Asia/Shanghai"),
|
||||
sa.Column("email_notifications", sa.Boolean, server_default="true"),
|
||||
sa.Column("usage_alerts", sa.Boolean, server_default="true"),
|
||||
sa.Column("announcement_notifications", sa.Boolean, server_default="true"),
|
||||
sa.Column(
|
||||
"created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||
),
|
||||
)
|
||||
|
||||
# ==================== announcements ====================
|
||||
op.create_table(
|
||||
"announcements",
|
||||
sa.Column("id", sa.String(36), primary_key=True, index=True),
|
||||
sa.Column("title", sa.String(200), nullable=False),
|
||||
sa.Column("content", sa.Text, nullable=False),
|
||||
sa.Column("type", sa.String(20), server_default="info"),
|
||||
sa.Column("priority", sa.Integer, server_default="0"),
|
||||
sa.Column(
|
||||
"author_id",
|
||||
sa.String(36),
|
||||
sa.ForeignKey("users.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
),
|
||||
sa.Column("is_active", sa.Boolean, server_default="true", index=True),
|
||||
sa.Column("is_pinned", sa.Boolean, server_default="false"),
|
||||
sa.Column("start_time", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("end_time", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.func.now(),
|
||||
nullable=False,
|
||||
index=True,
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||
),
|
||||
)
|
||||
|
||||
# ==================== announcement_reads ====================
|
||||
op.create_table(
|
||||
"announcement_reads",
|
||||
sa.Column("id", sa.String(36), primary_key=True, index=True),
|
||||
sa.Column(
|
||||
"user_id", sa.String(36), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False
|
||||
),
|
||||
sa.Column(
|
||||
"announcement_id", sa.String(36), sa.ForeignKey("announcements.id"), nullable=False
|
||||
),
|
||||
sa.Column(
|
||||
"read_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||
),
|
||||
sa.UniqueConstraint("user_id", "announcement_id", name="uq_user_announcement"),
|
||||
)
|
||||
|
||||
# ==================== audit_logs ====================
|
||||
op.create_table(
|
||||
"audit_logs",
|
||||
sa.Column("id", sa.String(36), primary_key=True, index=True),
|
||||
sa.Column("event_type", sa.String(50), nullable=False, index=True),
|
||||
sa.Column(
|
||||
"user_id",
|
||||
sa.String(36),
|
||||
sa.ForeignKey("users.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
),
|
||||
sa.Column("api_key_id", sa.String(36), nullable=True),
|
||||
sa.Column("description", sa.Text, nullable=False),
|
||||
sa.Column("ip_address", sa.String(45), nullable=True),
|
||||
sa.Column("user_agent", sa.String(500), nullable=True),
|
||||
sa.Column("request_id", sa.String(100), nullable=True, index=True),
|
||||
sa.Column("event_metadata", sa.JSON, nullable=True),
|
||||
sa.Column("status_code", sa.Integer, nullable=True),
|
||||
sa.Column("error_message", sa.Text, nullable=True),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.func.now(),
|
||||
nullable=False,
|
||||
index=True,
|
||||
),
|
||||
)
|
||||
|
||||
# ==================== request_candidates ====================
|
||||
op.create_table(
|
||||
"request_candidates",
|
||||
sa.Column("id", sa.String(36), primary_key=True),
|
||||
sa.Column("request_id", sa.String(100), nullable=False, index=True),
|
||||
sa.Column(
|
||||
"user_id", sa.String(36), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=True
|
||||
),
|
||||
sa.Column(
|
||||
"api_key_id",
|
||||
sa.String(36),
|
||||
sa.ForeignKey("api_keys.id", ondelete="CASCADE"),
|
||||
nullable=True,
|
||||
),
|
||||
sa.Column("candidate_index", sa.Integer, nullable=False),
|
||||
sa.Column("retry_index", sa.Integer, nullable=False, server_default="0"),
|
||||
sa.Column(
|
||||
"provider_id",
|
||||
sa.String(36),
|
||||
sa.ForeignKey("providers.id", ondelete="CASCADE"),
|
||||
nullable=True,
|
||||
),
|
||||
sa.Column(
|
||||
"endpoint_id",
|
||||
sa.String(36),
|
||||
sa.ForeignKey("provider_endpoints.id", ondelete="CASCADE"),
|
||||
nullable=True,
|
||||
),
|
||||
sa.Column(
|
||||
"key_id",
|
||||
sa.String(36),
|
||||
sa.ForeignKey("provider_api_keys.id", ondelete="CASCADE"),
|
||||
nullable=True,
|
||||
),
|
||||
sa.Column("status", sa.String(20), nullable=False),
|
||||
sa.Column("skip_reason", sa.Text, nullable=True),
|
||||
sa.Column("is_cached", sa.Boolean, server_default="false"),
|
||||
sa.Column("status_code", sa.Integer, nullable=True),
|
||||
sa.Column("error_type", sa.String(50), nullable=True),
|
||||
sa.Column("error_message", sa.Text, nullable=True),
|
||||
sa.Column("latency_ms", sa.Integer, nullable=True),
|
||||
sa.Column("concurrent_requests", sa.Integer, nullable=True),
|
||||
sa.Column("extra_data", sa.JSON, nullable=True),
|
||||
sa.Column("required_capabilities", sa.JSON, nullable=True),
|
||||
sa.Column(
|
||||
"created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||
),
|
||||
sa.Column("started_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("finished_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.UniqueConstraint(
|
||||
"request_id", "candidate_index", "retry_index", name="uq_request_candidate_with_retry"
|
||||
),
|
||||
)
|
||||
op.create_index("idx_request_candidates_request_id", "request_candidates", ["request_id"])
|
||||
op.create_index("idx_request_candidates_status", "request_candidates", ["status"])
|
||||
op.create_index("idx_request_candidates_provider_id", "request_candidates", ["provider_id"])
|
||||
|
||||
# ==================== stats_daily ====================
|
||||
op.create_table(
|
||||
"stats_daily",
|
||||
sa.Column("id", sa.String(36), primary_key=True),
|
||||
sa.Column("date", sa.DateTime(timezone=True), nullable=False, unique=True, index=True),
|
||||
sa.Column("total_requests", sa.Integer, server_default="0", nullable=False),
|
||||
sa.Column("success_requests", sa.Integer, server_default="0", nullable=False),
|
||||
sa.Column("error_requests", sa.Integer, server_default="0", nullable=False),
|
||||
sa.Column("input_tokens", sa.BigInteger, server_default="0", nullable=False),
|
||||
sa.Column("output_tokens", sa.BigInteger, server_default="0", nullable=False),
|
||||
sa.Column("cache_creation_tokens", sa.BigInteger, server_default="0", nullable=False),
|
||||
sa.Column("cache_read_tokens", sa.BigInteger, server_default="0", nullable=False),
|
||||
sa.Column("total_cost", sa.Float, server_default="0.0", nullable=False),
|
||||
sa.Column("actual_total_cost", sa.Float, server_default="0.0", nullable=False),
|
||||
sa.Column("input_cost", sa.Float, server_default="0.0", nullable=False),
|
||||
sa.Column("output_cost", sa.Float, server_default="0.0", nullable=False),
|
||||
sa.Column("cache_creation_cost", sa.Float, server_default="0.0", nullable=False),
|
||||
sa.Column("cache_read_cost", sa.Float, server_default="0.0", nullable=False),
|
||||
sa.Column("avg_response_time_ms", sa.Float, server_default="0.0", nullable=False),
|
||||
sa.Column("fallback_count", sa.Integer, server_default="0", nullable=False),
|
||||
sa.Column("unique_models", sa.Integer, server_default="0", nullable=False),
|
||||
sa.Column("unique_providers", sa.Integer, server_default="0", nullable=False),
|
||||
sa.Column(
|
||||
"created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||
),
|
||||
)
|
||||
|
||||
# ==================== stats_summary ====================
|
||||
op.create_table(
|
||||
"stats_summary",
|
||||
sa.Column("id", sa.String(36), primary_key=True),
|
||||
sa.Column("cutoff_date", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("all_time_requests", sa.Integer, server_default="0", nullable=False),
|
||||
sa.Column("all_time_success_requests", sa.Integer, server_default="0", nullable=False),
|
||||
sa.Column("all_time_error_requests", sa.Integer, server_default="0", nullable=False),
|
||||
sa.Column("all_time_input_tokens", sa.BigInteger, server_default="0", nullable=False),
|
||||
sa.Column("all_time_output_tokens", sa.BigInteger, server_default="0", nullable=False),
|
||||
sa.Column(
|
||||
"all_time_cache_creation_tokens", sa.BigInteger, server_default="0", nullable=False
|
||||
),
|
||||
sa.Column("all_time_cache_read_tokens", sa.BigInteger, server_default="0", nullable=False),
|
||||
sa.Column("all_time_cost", sa.Float, server_default="0.0", nullable=False),
|
||||
sa.Column("all_time_actual_cost", sa.Float, server_default="0.0", nullable=False),
|
||||
sa.Column("total_users", sa.Integer, server_default="0", nullable=False),
|
||||
sa.Column("active_users", sa.Integer, server_default="0", nullable=False),
|
||||
sa.Column("total_api_keys", sa.Integer, server_default="0", nullable=False),
|
||||
sa.Column("active_api_keys", sa.Integer, server_default="0", nullable=False),
|
||||
sa.Column(
|
||||
"created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||
),
|
||||
)
|
||||
|
||||
# ==================== stats_user_daily ====================
|
||||
op.create_table(
|
||||
"stats_user_daily",
|
||||
sa.Column("id", sa.String(36), primary_key=True),
|
||||
sa.Column(
|
||||
"user_id", sa.String(36), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False
|
||||
),
|
||||
sa.Column("date", sa.DateTime(timezone=True), nullable=False, index=True),
|
||||
sa.Column("total_requests", sa.Integer, server_default="0", nullable=False),
|
||||
sa.Column("success_requests", sa.Integer, server_default="0", nullable=False),
|
||||
sa.Column("error_requests", sa.Integer, server_default="0", nullable=False),
|
||||
sa.Column("input_tokens", sa.BigInteger, server_default="0", nullable=False),
|
||||
sa.Column("output_tokens", sa.BigInteger, server_default="0", nullable=False),
|
||||
sa.Column("cache_creation_tokens", sa.BigInteger, server_default="0", nullable=False),
|
||||
sa.Column("cache_read_tokens", sa.BigInteger, server_default="0", nullable=False),
|
||||
sa.Column("total_cost", sa.Float, server_default="0.0", nullable=False),
|
||||
sa.Column(
|
||||
"created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||
),
|
||||
sa.UniqueConstraint("user_id", "date", name="uq_stats_user_daily"),
|
||||
)
|
||||
op.create_index("idx_stats_user_daily_user_date", "stats_user_daily", ["user_id", "date"])
|
||||
|
||||
# ==================== api_key_provider_mappings ====================
|
||||
op.create_table(
|
||||
"api_key_provider_mappings",
|
||||
sa.Column("id", sa.String(36), primary_key=True, index=True),
|
||||
sa.Column(
|
||||
"api_key_id",
|
||||
sa.String(36),
|
||||
sa.ForeignKey("api_keys.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
),
|
||||
sa.Column(
|
||||
"provider_id",
|
||||
sa.String(36),
|
||||
sa.ForeignKey("providers.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
),
|
||||
sa.Column("priority_adjustment", sa.Integer, server_default="0"),
|
||||
sa.Column("weight_multiplier", sa.Float, server_default="1.0"),
|
||||
sa.Column("is_enabled", sa.Boolean, server_default="true", nullable=False),
|
||||
sa.Column(
|
||||
"created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||
),
|
||||
sa.UniqueConstraint("api_key_id", "provider_id", name="uq_apikey_provider"),
|
||||
)
|
||||
op.create_index(
|
||||
"idx_apikey_provider_enabled", "api_key_provider_mappings", ["api_key_id", "is_enabled"]
|
||||
)
|
||||
|
||||
# ==================== provider_usage_tracking ====================
|
||||
op.create_table(
|
||||
"provider_usage_tracking",
|
||||
sa.Column("id", sa.String(36), primary_key=True, index=True),
|
||||
sa.Column(
|
||||
"provider_id",
|
||||
sa.String(36),
|
||||
sa.ForeignKey("providers.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
),
|
||||
sa.Column("window_start", sa.DateTime(timezone=True), nullable=False, index=True),
|
||||
sa.Column("window_end", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("total_requests", sa.Integer, server_default="0"),
|
||||
sa.Column("successful_requests", sa.Integer, server_default="0"),
|
||||
sa.Column("failed_requests", sa.Integer, server_default="0"),
|
||||
sa.Column("avg_response_time_ms", sa.Float, server_default="0.0"),
|
||||
sa.Column("total_response_time_ms", sa.Float, server_default="0.0"),
|
||||
sa.Column("total_cost_usd", sa.Float, server_default="0.0"),
|
||||
sa.Column(
|
||||
"created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||
),
|
||||
)
|
||||
op.create_index(
|
||||
"idx_provider_window", "provider_usage_tracking", ["provider_id", "window_start"]
|
||||
)
|
||||
op.create_index("idx_window_time", "provider_usage_tracking", ["window_start", "window_end"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Drop tables in reverse order (respecting foreign key dependencies)
|
||||
op.drop_table("provider_usage_tracking")
|
||||
op.drop_table("api_key_provider_mappings")
|
||||
op.drop_table("stats_user_daily")
|
||||
op.drop_table("stats_summary")
|
||||
op.drop_table("stats_daily")
|
||||
op.drop_table("request_candidates")
|
||||
op.drop_table("audit_logs")
|
||||
op.drop_table("announcement_reads")
|
||||
op.drop_table("announcements")
|
||||
op.drop_table("user_preferences")
|
||||
op.drop_table("system_configs")
|
||||
op.drop_table("user_quotas")
|
||||
op.drop_table("usage")
|
||||
op.drop_table("provider_api_keys")
|
||||
op.drop_table("model_mappings")
|
||||
op.drop_table("models")
|
||||
op.drop_table("provider_endpoints")
|
||||
op.drop_table("api_keys")
|
||||
op.drop_table("global_models")
|
||||
op.drop_table("providers")
|
||||
op.drop_table("users")
|
||||
|
||||
# Drop ENUM types
|
||||
op.execute("DROP TYPE IF EXISTS providerbillingtype")
|
||||
op.execute("DROP TYPE IF EXISTS userrole")
|
||||
@@ -0,0 +1,315 @@
|
||||
"""remove_model_mappings_add_aliases
|
||||
|
||||
合并迁移:
|
||||
1. 添加 provider_model_aliases 字段到 models 表
|
||||
2. 迁移 model_mappings 数据到 provider_model_aliases
|
||||
3. 删除 model_mappings 表
|
||||
4. 添加索引优化别名解析性能
|
||||
|
||||
Revision ID: e9b3d63f0cbf
|
||||
Revises: 20251210_baseline
|
||||
Create Date: 2025-12-14 13:00:22.828183+00:00
|
||||
|
||||
"""
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'e9b3d63f0cbf'
|
||||
down_revision = '20251210_baseline'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def column_exists(bind, table_name: str, column_name: str) -> bool:
|
||||
"""检查列是否存在"""
|
||||
result = bind.execute(
|
||||
sa.text(
|
||||
"""
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = :table_name AND column_name = :column_name
|
||||
)
|
||||
"""
|
||||
),
|
||||
{"table_name": table_name, "column_name": column_name},
|
||||
)
|
||||
return result.scalar()
|
||||
|
||||
|
||||
def table_exists(bind, table_name: str) -> bool:
|
||||
"""检查表是否存在"""
|
||||
result = bind.execute(
|
||||
sa.text(
|
||||
"""
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM information_schema.tables
|
||||
WHERE table_name = :table_name
|
||||
)
|
||||
"""
|
||||
),
|
||||
{"table_name": table_name},
|
||||
)
|
||||
return result.scalar()
|
||||
|
||||
|
||||
def index_exists(bind, index_name: str) -> bool:
|
||||
"""检查索引是否存在"""
|
||||
result = bind.execute(
|
||||
sa.text(
|
||||
"""
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM pg_indexes
|
||||
WHERE indexname = :index_name
|
||||
)
|
||||
"""
|
||||
),
|
||||
{"index_name": index_name},
|
||||
)
|
||||
return result.scalar()
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""添加 provider_model_aliases 字段,迁移数据,删除 model_mappings 表"""
|
||||
bind = op.get_bind()
|
||||
|
||||
# 1. 添加 provider_model_aliases 字段(如果不存在)
|
||||
if not column_exists(bind, "models", "provider_model_aliases"):
|
||||
op.add_column(
|
||||
'models',
|
||||
sa.Column('provider_model_aliases', sa.JSON(), nullable=True)
|
||||
)
|
||||
|
||||
# 2. 迁移 model_mappings 数据(如果表存在)
|
||||
session = Session(bind=bind)
|
||||
|
||||
model_mappings_table = sa.table(
|
||||
"model_mappings",
|
||||
sa.column("source_model", sa.String),
|
||||
sa.column("target_global_model_id", sa.String),
|
||||
sa.column("provider_id", sa.String),
|
||||
sa.column("mapping_type", sa.String),
|
||||
sa.column("is_active", sa.Boolean),
|
||||
)
|
||||
|
||||
models_table = sa.table(
|
||||
"models",
|
||||
sa.column("id", sa.String),
|
||||
sa.column("provider_id", sa.String),
|
||||
sa.column("global_model_id", sa.String),
|
||||
sa.column("provider_model_aliases", sa.JSON),
|
||||
sa.column("updated_at", sa.DateTime(timezone=True)),
|
||||
)
|
||||
|
||||
def normalize_alias_list(value) -> list[dict]:
|
||||
"""将 DB 返回的 JSON 值规范化为 list[{'name': str, 'priority': int}]"""
|
||||
if value is None:
|
||||
return []
|
||||
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
value = json.loads(value) if value else []
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
if not isinstance(value, list):
|
||||
return []
|
||||
|
||||
normalized: list[dict] = []
|
||||
for item in value:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
|
||||
raw_name = item.get("name")
|
||||
if not isinstance(raw_name, str):
|
||||
continue
|
||||
name = raw_name.strip()
|
||||
if not name:
|
||||
continue
|
||||
|
||||
raw_priority = item.get("priority", 1)
|
||||
try:
|
||||
priority = int(raw_priority)
|
||||
except Exception:
|
||||
priority = 1
|
||||
if priority < 1:
|
||||
priority = 1
|
||||
|
||||
normalized.append({"name": name, "priority": priority})
|
||||
|
||||
return normalized
|
||||
|
||||
# 查询所有活跃的 provider 级别 alias(只迁移 is_active=True 且 mapping_type='alias' 的)
|
||||
# 全局别名/映射不迁移(新架构不再支持 source_model -> GlobalModel.name 的解析)
|
||||
# 仅当 model_mappings 表存在时执行迁移
|
||||
if table_exists(bind, "model_mappings"):
|
||||
mappings = session.execute(
|
||||
sa.select(
|
||||
model_mappings_table.c.source_model,
|
||||
model_mappings_table.c.target_global_model_id,
|
||||
model_mappings_table.c.provider_id,
|
||||
)
|
||||
.where(
|
||||
model_mappings_table.c.is_active.is_(True),
|
||||
model_mappings_table.c.provider_id.isnot(None),
|
||||
model_mappings_table.c.mapping_type == "alias",
|
||||
)
|
||||
.order_by(model_mappings_table.c.provider_id, model_mappings_table.c.source_model)
|
||||
).all()
|
||||
|
||||
# 按 (provider_id, target_global_model_id) 分组,收集别名
|
||||
alias_groups: dict = {}
|
||||
for source_model, target_global_model_id, provider_id in mappings:
|
||||
if not isinstance(source_model, str):
|
||||
continue
|
||||
source_model = source_model.strip()
|
||||
if not source_model:
|
||||
continue
|
||||
if not isinstance(provider_id, str) or not provider_id:
|
||||
continue
|
||||
if not isinstance(target_global_model_id, str) or not target_global_model_id:
|
||||
continue
|
||||
|
||||
key = (provider_id, target_global_model_id)
|
||||
if key not in alias_groups:
|
||||
alias_groups[key] = []
|
||||
priority = len(alias_groups[key]) + 1
|
||||
alias_groups[key].append({"name": source_model, "priority": priority})
|
||||
|
||||
# 更新对应的 models 记录
|
||||
for (provider_id, global_model_id), aliases in alias_groups.items():
|
||||
model_row = session.execute(
|
||||
sa.select(models_table.c.id, models_table.c.provider_model_aliases)
|
||||
.where(
|
||||
models_table.c.provider_id == provider_id,
|
||||
models_table.c.global_model_id == global_model_id,
|
||||
)
|
||||
.limit(1)
|
||||
).first()
|
||||
|
||||
if model_row:
|
||||
model_id = model_row[0]
|
||||
existing_aliases = normalize_alias_list(model_row[1])
|
||||
|
||||
existing_names = {a["name"] for a in existing_aliases}
|
||||
merged_aliases = list(existing_aliases)
|
||||
for alias in aliases:
|
||||
name = alias.get("name")
|
||||
if not isinstance(name, str):
|
||||
continue
|
||||
name = name.strip()
|
||||
if not name or name in existing_names:
|
||||
continue
|
||||
|
||||
merged_aliases.append(
|
||||
{
|
||||
"name": name,
|
||||
"priority": len(merged_aliases) + 1,
|
||||
}
|
||||
)
|
||||
existing_names.add(name)
|
||||
|
||||
session.execute(
|
||||
models_table.update()
|
||||
.where(models_table.c.id == model_id)
|
||||
.values(
|
||||
provider_model_aliases=merged_aliases if merged_aliases else None,
|
||||
updated_at=datetime.now(timezone.utc),
|
||||
)
|
||||
)
|
||||
|
||||
session.commit()
|
||||
|
||||
# 3. 删除 model_mappings 表
|
||||
op.drop_table('model_mappings')
|
||||
|
||||
# 4. 添加索引优化别名解析性能
|
||||
# provider_model_name 索引(支持精确匹配,如果不存在)
|
||||
if not index_exists(bind, "idx_model_provider_model_name"):
|
||||
op.create_index(
|
||||
"idx_model_provider_model_name",
|
||||
"models",
|
||||
["provider_model_name"],
|
||||
unique=False,
|
||||
postgresql_where=sa.text("is_active = true"),
|
||||
)
|
||||
|
||||
# provider_model_aliases GIN 索引(支持 JSONB 查询,仅 PostgreSQL)
|
||||
if bind.dialect.name == "postgresql":
|
||||
# 将 json 列转为 jsonb(jsonb 性能更好且支持 GIN 索引)
|
||||
# 使用 IF NOT EXISTS 风格的检查来避免重复转换
|
||||
op.execute(
|
||||
"""
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'models'
|
||||
AND column_name = 'provider_model_aliases'
|
||||
AND data_type = 'json'
|
||||
) THEN
|
||||
ALTER TABLE models
|
||||
ALTER COLUMN provider_model_aliases TYPE jsonb
|
||||
USING provider_model_aliases::jsonb;
|
||||
END IF;
|
||||
END $$;
|
||||
"""
|
||||
)
|
||||
# 创建 GIN 索引
|
||||
op.execute(
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS idx_model_provider_model_aliases_gin
|
||||
ON models USING gin(provider_model_aliases jsonb_path_ops)
|
||||
WHERE is_active = true
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""恢复 model_mappings 表,移除 provider_model_aliases 字段和索引"""
|
||||
bind = op.get_bind()
|
||||
|
||||
# 1. 删除索引
|
||||
op.drop_index("idx_model_provider_model_name", table_name="models")
|
||||
|
||||
if bind.dialect.name == "postgresql":
|
||||
op.execute("DROP INDEX IF EXISTS idx_model_provider_model_aliases_gin")
|
||||
# 将 jsonb 列还原为 json
|
||||
op.execute(
|
||||
"""
|
||||
ALTER TABLE models
|
||||
ALTER COLUMN provider_model_aliases TYPE json
|
||||
USING provider_model_aliases::json
|
||||
"""
|
||||
)
|
||||
|
||||
# 2. 恢复 model_mappings 表
|
||||
op.create_table(
|
||||
'model_mappings',
|
||||
sa.Column('id', sa.String(36), primary_key=True),
|
||||
sa.Column('source_model', sa.String(200), nullable=False),
|
||||
sa.Column(
|
||||
'target_global_model_id',
|
||||
sa.String(36),
|
||||
sa.ForeignKey('global_models.id', ondelete='CASCADE'),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column('provider_id', sa.String(36), sa.ForeignKey('providers.id'), nullable=True),
|
||||
sa.Column('mapping_type', sa.String(20), nullable=False, server_default='alias'),
|
||||
sa.Column('is_active', sa.Boolean(), nullable=False, server_default='true'),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.UniqueConstraint('source_model', 'provider_id', name='uq_model_mapping_source_provider'),
|
||||
)
|
||||
op.create_index('ix_model_mappings_source_model', 'model_mappings', ['source_model'])
|
||||
op.create_index('ix_model_mappings_target_global_model_id', 'model_mappings', ['target_global_model_id'])
|
||||
op.create_index('ix_model_mappings_provider_id', 'model_mappings', ['provider_id'])
|
||||
op.create_index('ix_model_mappings_mapping_type', 'model_mappings', ['mapping_type'])
|
||||
|
||||
# 3. 移除 provider_model_aliases 字段
|
||||
op.drop_column('models', 'provider_model_aliases')
|
||||
@@ -0,0 +1,47 @@
|
||||
"""add first_byte_time_ms to usage table
|
||||
|
||||
Revision ID: 180e63a9c83a
|
||||
Revises: e9b3d63f0cbf
|
||||
Create Date: 2025-12-15 17:07:44.631032+00:00
|
||||
|
||||
"""
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '180e63a9c83a'
|
||||
down_revision = 'e9b3d63f0cbf'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def column_exists(bind, table_name: str, column_name: str) -> bool:
|
||||
"""检查列是否存在"""
|
||||
result = bind.execute(
|
||||
sa.text(
|
||||
"""
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = :table_name AND column_name = :column_name
|
||||
)
|
||||
"""
|
||||
),
|
||||
{"table_name": table_name, "column_name": column_name},
|
||||
)
|
||||
return result.scalar()
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""应用迁移:升级到新版本"""
|
||||
bind = op.get_bind()
|
||||
|
||||
# 添加首字时间字段到 usage 表(如果不存在)
|
||||
if not column_exists(bind, "usage", "first_byte_time_ms"):
|
||||
op.add_column('usage', sa.Column('first_byte_time_ms', sa.Integer(), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""回滚迁移:降级到旧版本"""
|
||||
# 删除首字时间字段
|
||||
op.drop_column('usage', 'first_byte_time_ms')
|
||||
@@ -0,0 +1,110 @@
|
||||
"""refactor global_model to use config json field
|
||||
|
||||
Revision ID: 1cc6942cf06f
|
||||
Revises: 180e63a9c83a
|
||||
Create Date: 2025-12-16 03:11:32.480976+00:00
|
||||
|
||||
"""
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '1cc6942cf06f'
|
||||
down_revision = '180e63a9c83a'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def column_exists(bind, table_name: str, column_name: str) -> bool:
|
||||
"""检查列是否存在"""
|
||||
result = bind.execute(
|
||||
sa.text(
|
||||
"""
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = :table_name AND column_name = :column_name
|
||||
)
|
||||
"""
|
||||
),
|
||||
{"table_name": table_name, "column_name": column_name},
|
||||
)
|
||||
return result.scalar()
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""应用迁移:升级到新版本
|
||||
|
||||
1. 添加 config 列
|
||||
2. 把旧数据迁移到 config
|
||||
3. 删除旧列
|
||||
"""
|
||||
bind = op.get_bind()
|
||||
|
||||
# 检查是否已经迁移过(config 列存在且旧列不存在)
|
||||
has_config = column_exists(bind, "global_models", "config")
|
||||
has_old_columns = column_exists(bind, "global_models", "default_supports_streaming")
|
||||
|
||||
if has_config and not has_old_columns:
|
||||
# 已完成迁移,跳过
|
||||
return
|
||||
|
||||
# 1. 添加 config 列(使用 JSONB 类型,支持索引和更高效的查询)
|
||||
if not has_config:
|
||||
op.add_column('global_models', sa.Column('config', postgresql.JSONB(), nullable=True))
|
||||
|
||||
# 2. 迁移数据:把旧字段合并到 config JSON(仅当旧列存在时)
|
||||
if has_old_columns:
|
||||
op.execute("""
|
||||
UPDATE global_models
|
||||
SET config = jsonb_strip_nulls(jsonb_build_object(
|
||||
'streaming', COALESCE(default_supports_streaming, true),
|
||||
'vision', CASE WHEN COALESCE(default_supports_vision, false) THEN true ELSE NULL END,
|
||||
'function_calling', CASE WHEN COALESCE(default_supports_function_calling, false) THEN true ELSE NULL END,
|
||||
'extended_thinking', CASE WHEN COALESCE(default_supports_extended_thinking, false) THEN true ELSE NULL END,
|
||||
'image_generation', CASE WHEN COALESCE(default_supports_image_generation, false) THEN true ELSE NULL END,
|
||||
'description', description,
|
||||
'icon_url', icon_url,
|
||||
'official_url', official_url
|
||||
))
|
||||
""")
|
||||
|
||||
# 3. 删除旧列
|
||||
op.drop_column('global_models', 'default_supports_streaming')
|
||||
op.drop_column('global_models', 'default_supports_vision')
|
||||
op.drop_column('global_models', 'default_supports_function_calling')
|
||||
op.drop_column('global_models', 'default_supports_extended_thinking')
|
||||
op.drop_column('global_models', 'default_supports_image_generation')
|
||||
op.drop_column('global_models', 'description')
|
||||
op.drop_column('global_models', 'icon_url')
|
||||
op.drop_column('global_models', 'official_url')
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""回滚迁移:降级到旧版本"""
|
||||
# 1. 添加旧列
|
||||
op.add_column('global_models', sa.Column('icon_url', sa.VARCHAR(length=500), nullable=True))
|
||||
op.add_column('global_models', sa.Column('official_url', sa.VARCHAR(length=500), nullable=True))
|
||||
op.add_column('global_models', sa.Column('description', sa.TEXT(), nullable=True))
|
||||
op.add_column('global_models', sa.Column('default_supports_streaming', sa.BOOLEAN(), nullable=True))
|
||||
op.add_column('global_models', sa.Column('default_supports_vision', sa.BOOLEAN(), nullable=True))
|
||||
op.add_column('global_models', sa.Column('default_supports_function_calling', sa.BOOLEAN(), nullable=True))
|
||||
op.add_column('global_models', sa.Column('default_supports_extended_thinking', sa.BOOLEAN(), nullable=True))
|
||||
op.add_column('global_models', sa.Column('default_supports_image_generation', sa.BOOLEAN(), nullable=True))
|
||||
|
||||
# 2. 从 config 恢复数据
|
||||
op.execute("""
|
||||
UPDATE global_models
|
||||
SET
|
||||
default_supports_streaming = COALESCE((config->>'streaming')::boolean, true),
|
||||
default_supports_vision = COALESCE((config->>'vision')::boolean, false),
|
||||
default_supports_function_calling = COALESCE((config->>'function_calling')::boolean, false),
|
||||
default_supports_extended_thinking = COALESCE((config->>'extended_thinking')::boolean, false),
|
||||
default_supports_image_generation = COALESCE((config->>'image_generation')::boolean, false),
|
||||
description = config->>'description',
|
||||
icon_url = config->>'icon_url',
|
||||
official_url = config->>'official_url'
|
||||
""")
|
||||
|
||||
# 3. 删除 config 列
|
||||
op.drop_column('global_models', 'config')
|
||||
@@ -0,0 +1,57 @@
|
||||
"""add proxy field to provider_endpoints
|
||||
|
||||
Revision ID: f30f9936f6a2
|
||||
Revises: 1cc6942cf06f
|
||||
Create Date: 2025-12-18 06:31:58.451112+00:00
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy import inspect
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'f30f9936f6a2'
|
||||
down_revision = '1cc6942cf06f'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def column_exists(table_name: str, column_name: str) -> bool:
|
||||
"""检查列是否存在"""
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
columns = [col['name'] for col in inspector.get_columns(table_name)]
|
||||
return column_name in columns
|
||||
|
||||
|
||||
def get_column_type(table_name: str, column_name: str) -> str:
|
||||
"""获取列的类型"""
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
for col in inspector.get_columns(table_name):
|
||||
if col['name'] == column_name:
|
||||
return str(col['type']).upper()
|
||||
return ''
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""添加 proxy 字段到 provider_endpoints 表"""
|
||||
if not column_exists('provider_endpoints', 'proxy'):
|
||||
# 字段不存在,直接添加 JSONB 类型
|
||||
op.add_column('provider_endpoints', sa.Column('proxy', JSONB(), nullable=True))
|
||||
else:
|
||||
# 字段已存在,检查是否需要转换类型
|
||||
col_type = get_column_type('provider_endpoints', 'proxy')
|
||||
if 'JSONB' not in col_type:
|
||||
# 如果是 JSON 类型,转换为 JSONB
|
||||
op.execute(
|
||||
'ALTER TABLE provider_endpoints '
|
||||
'ALTER COLUMN proxy TYPE JSONB USING proxy::jsonb'
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""移除 proxy 字段"""
|
||||
if column_exists('provider_endpoints', 'proxy'):
|
||||
op.drop_column('provider_endpoints', 'proxy')
|
||||
@@ -0,0 +1,86 @@
|
||||
"""add stats_daily_model table and rename provider_model_aliases
|
||||
|
||||
Revision ID: a1b2c3d4e5f6
|
||||
Revises: f30f9936f6a2
|
||||
Create Date: 2025-12-20 12:00:00.000000+00:00
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy import inspect
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'a1b2c3d4e5f6'
|
||||
down_revision = 'f30f9936f6a2'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def table_exists(table_name: str) -> bool:
|
||||
"""检查表是否存在"""
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
return table_name in inspector.get_table_names()
|
||||
|
||||
|
||||
def column_exists(table_name: str, column_name: str) -> bool:
|
||||
"""检查列是否存在"""
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
columns = [col['name'] for col in inspector.get_columns(table_name)]
|
||||
return column_name in columns
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""创建 stats_daily_model 表,重命名 provider_model_aliases 为 provider_model_mappings"""
|
||||
# 1. 创建 stats_daily_model 表
|
||||
if not table_exists('stats_daily_model'):
|
||||
op.create_table(
|
||||
'stats_daily_model',
|
||||
sa.Column('id', sa.String(36), primary_key=True),
|
||||
sa.Column('date', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column('model', sa.String(100), nullable=False),
|
||||
sa.Column('total_requests', sa.Integer(), nullable=False, default=0),
|
||||
sa.Column('input_tokens', sa.BigInteger(), nullable=False, default=0),
|
||||
sa.Column('output_tokens', sa.BigInteger(), nullable=False, default=0),
|
||||
sa.Column('cache_creation_tokens', sa.BigInteger(), nullable=False, default=0),
|
||||
sa.Column('cache_read_tokens', sa.BigInteger(), nullable=False, default=0),
|
||||
sa.Column('total_cost', sa.Float(), nullable=False, default=0.0),
|
||||
sa.Column('avg_response_time_ms', sa.Float(), nullable=False, default=0.0),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False,
|
||||
server_default=sa.func.now()),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False,
|
||||
server_default=sa.func.now(), onupdate=sa.func.now()),
|
||||
sa.UniqueConstraint('date', 'model', name='uq_stats_daily_model'),
|
||||
)
|
||||
|
||||
# 创建索引
|
||||
op.create_index('idx_stats_daily_model_date', 'stats_daily_model', ['date'])
|
||||
op.create_index('idx_stats_daily_model_date_model', 'stats_daily_model', ['date', 'model'])
|
||||
|
||||
# 2. 重命名 models 表的 provider_model_aliases 为 provider_model_mappings
|
||||
if column_exists('models', 'provider_model_aliases') and not column_exists('models', 'provider_model_mappings'):
|
||||
op.alter_column('models', 'provider_model_aliases', new_column_name='provider_model_mappings')
|
||||
|
||||
|
||||
def index_exists(table_name: str, index_name: str) -> bool:
|
||||
"""检查索引是否存在"""
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
indexes = [idx['name'] for idx in inspector.get_indexes(table_name)]
|
||||
return index_name in indexes
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""删除 stats_daily_model 表,恢复 provider_model_aliases 列名"""
|
||||
# 恢复列名
|
||||
if column_exists('models', 'provider_model_mappings') and not column_exists('models', 'provider_model_aliases'):
|
||||
op.alter_column('models', 'provider_model_mappings', new_column_name='provider_model_aliases')
|
||||
|
||||
# 删除表
|
||||
if table_exists('stats_daily_model'):
|
||||
if index_exists('stats_daily_model', 'idx_stats_daily_model_date_model'):
|
||||
op.drop_index('idx_stats_daily_model_date_model', table_name='stats_daily_model')
|
||||
if index_exists('stats_daily_model', 'idx_stats_daily_model_date'):
|
||||
op.drop_index('idx_stats_daily_model_date', table_name='stats_daily_model')
|
||||
op.drop_table('stats_daily_model')
|
||||
@@ -0,0 +1,65 @@
|
||||
"""add usage table composite indexes for query optimization
|
||||
|
||||
Revision ID: b2c3d4e5f6g7
|
||||
Revises: a1b2c3d4e5f6
|
||||
Create Date: 2025-12-20 15:00:00.000000+00:00
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
from sqlalchemy import text
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'b2c3d4e5f6g7'
|
||||
down_revision = 'a1b2c3d4e5f6'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""为 usage 表添加复合索引以优化常见查询
|
||||
|
||||
注意:这些索引已经在 baseline 迁移中创建。
|
||||
此迁移仅用于从旧版本升级的场景,新安装会跳过。
|
||||
"""
|
||||
conn = op.get_bind()
|
||||
|
||||
# 检查 usage 表是否存在
|
||||
result = conn.execute(text(
|
||||
"SELECT EXISTS (SELECT FROM information_schema.tables WHERE table_name = 'usage')"
|
||||
))
|
||||
if not result.scalar():
|
||||
# 表不存在,跳过
|
||||
return
|
||||
|
||||
# 定义需要创建的索引
|
||||
indexes = [
|
||||
("idx_usage_user_created", "ON usage (user_id, created_at)"),
|
||||
("idx_usage_apikey_created", "ON usage (api_key_id, created_at)"),
|
||||
("idx_usage_provider_model_created", "ON usage (provider, model, created_at)"),
|
||||
]
|
||||
|
||||
# 分别检查并创建每个索引
|
||||
for index_name, index_def in indexes:
|
||||
result = conn.execute(text(
|
||||
f"SELECT EXISTS (SELECT 1 FROM pg_indexes WHERE indexname = '{index_name}')"
|
||||
))
|
||||
if result.scalar():
|
||||
continue # 索引已存在,跳过
|
||||
|
||||
conn.execute(text(f"CREATE INDEX {index_name} {index_def}"))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""删除复合索引"""
|
||||
conn = op.get_bind()
|
||||
|
||||
# 使用 IF EXISTS 避免索引不存在时报错
|
||||
conn.execute(text(
|
||||
"DROP INDEX IF EXISTS idx_usage_provider_model_created"
|
||||
))
|
||||
conn.execute(text(
|
||||
"DROP INDEX IF EXISTS idx_usage_apikey_created"
|
||||
))
|
||||
conn.execute(text(
|
||||
"DROP INDEX IF EXISTS idx_usage_user_created"
|
||||
))
|
||||
@@ -0,0 +1,161 @@
|
||||
"""add ldap authentication support
|
||||
|
||||
Revision ID: c3d4e5f6g7h8
|
||||
Revises: b2c3d4e5f6g7
|
||||
Create Date: 2026-01-01 14:00:00.000000+00:00
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy import text
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'c3d4e5f6g7h8'
|
||||
down_revision = 'b2c3d4e5f6g7'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _type_exists(conn, type_name: str) -> bool:
|
||||
"""检查 PostgreSQL 类型是否存在"""
|
||||
result = conn.execute(
|
||||
text("SELECT 1 FROM pg_type WHERE typname = :name"),
|
||||
{"name": type_name}
|
||||
)
|
||||
return result.scalar() is not None
|
||||
|
||||
|
||||
def _column_exists(conn, table_name: str, column_name: str) -> bool:
|
||||
"""检查列是否存在"""
|
||||
result = conn.execute(
|
||||
text("""
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = :table AND column_name = :column
|
||||
"""),
|
||||
{"table": table_name, "column": column_name}
|
||||
)
|
||||
return result.scalar() is not None
|
||||
|
||||
|
||||
def _index_exists(conn, index_name: str) -> bool:
|
||||
"""检查索引是否存在"""
|
||||
result = conn.execute(
|
||||
text("SELECT 1 FROM pg_indexes WHERE indexname = :name"),
|
||||
{"name": index_name}
|
||||
)
|
||||
return result.scalar() is not None
|
||||
|
||||
|
||||
def _table_exists(conn, table_name: str) -> bool:
|
||||
"""检查表是否存在"""
|
||||
result = conn.execute(
|
||||
text("""
|
||||
SELECT 1 FROM information_schema.tables
|
||||
WHERE table_name = :name AND table_schema = 'public'
|
||||
"""),
|
||||
{"name": table_name}
|
||||
)
|
||||
return result.scalar() is not None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""添加 LDAP 认证支持
|
||||
|
||||
1. 创建 authsource 枚举类型
|
||||
2. 在 users 表添加 auth_source 字段和 LDAP 标识字段
|
||||
3. 创建 ldap_configs 表
|
||||
"""
|
||||
conn = op.get_bind()
|
||||
|
||||
# 1. 创建 authsource 枚举类型(幂等)
|
||||
if not _type_exists(conn, 'authsource'):
|
||||
conn.execute(text("CREATE TYPE authsource AS ENUM ('local', 'ldap')"))
|
||||
|
||||
# 2. 在 users 表添加字段(幂等)
|
||||
if not _column_exists(conn, 'users', 'auth_source'):
|
||||
op.add_column('users', sa.Column(
|
||||
'auth_source',
|
||||
sa.Enum('local', 'ldap', name='authsource', create_type=False),
|
||||
nullable=False,
|
||||
server_default='local'
|
||||
))
|
||||
|
||||
if not _column_exists(conn, 'users', 'ldap_dn'):
|
||||
op.add_column('users', sa.Column('ldap_dn', sa.String(length=512), nullable=True))
|
||||
|
||||
if not _column_exists(conn, 'users', 'ldap_username'):
|
||||
op.add_column('users', sa.Column('ldap_username', sa.String(length=255), nullable=True))
|
||||
|
||||
# 创建索引(幂等)
|
||||
if not _index_exists(conn, 'ix_users_ldap_dn'):
|
||||
op.create_index('ix_users_ldap_dn', 'users', ['ldap_dn'])
|
||||
|
||||
if not _index_exists(conn, 'ix_users_ldap_username'):
|
||||
op.create_index('ix_users_ldap_username', 'users', ['ldap_username'])
|
||||
|
||||
# 3. 创建 ldap_configs 表(幂等)
|
||||
if not _table_exists(conn, 'ldap_configs'):
|
||||
op.create_table(
|
||||
'ldap_configs',
|
||||
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column('server_url', sa.String(length=255), nullable=False),
|
||||
sa.Column('bind_dn', sa.String(length=255), nullable=False),
|
||||
sa.Column('bind_password_encrypted', sa.Text(), nullable=True),
|
||||
sa.Column('base_dn', sa.String(length=255), nullable=False),
|
||||
sa.Column('user_search_filter', sa.String(length=500), nullable=False, server_default='(uid={username})'),
|
||||
sa.Column('username_attr', sa.String(length=50), nullable=False, server_default='uid'),
|
||||
sa.Column('email_attr', sa.String(length=50), nullable=False, server_default='mail'),
|
||||
sa.Column('display_name_attr', sa.String(length=50), nullable=False, server_default='cn'),
|
||||
sa.Column('is_enabled', sa.Boolean(), nullable=False, server_default='false'),
|
||||
sa.Column('is_exclusive', sa.Boolean(), nullable=False, server_default='false'),
|
||||
sa.Column('use_starttls', sa.Boolean(), nullable=False, server_default='false'),
|
||||
sa.Column('connect_timeout', sa.Integer(), nullable=False, server_default='10'),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.text('now()')),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.text('now()')),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""回滚 LDAP 认证支持
|
||||
|
||||
警告:回滚前请确保:
|
||||
1. 已备份数据库
|
||||
2. 没有 LDAP 用户需要保留
|
||||
"""
|
||||
conn = op.get_bind()
|
||||
|
||||
# 检查是否存在 LDAP 用户,防止数据丢失
|
||||
if _column_exists(conn, 'users', 'auth_source'):
|
||||
result = conn.execute(text("SELECT COUNT(*) FROM users WHERE auth_source = 'ldap'"))
|
||||
ldap_user_count = result.scalar()
|
||||
if ldap_user_count and ldap_user_count > 0:
|
||||
raise RuntimeError(
|
||||
f"无法回滚:存在 {ldap_user_count} 个 LDAP 用户。"
|
||||
f"请先删除或转换这些用户,或使用 --force 参数强制回滚(将丢失数据)。"
|
||||
)
|
||||
|
||||
# 1. 删除 ldap_configs 表(幂等)
|
||||
if _table_exists(conn, 'ldap_configs'):
|
||||
op.drop_table('ldap_configs')
|
||||
|
||||
# 2. 删除 users 表的 LDAP 相关字段(幂等)
|
||||
if _index_exists(conn, 'ix_users_ldap_username'):
|
||||
op.drop_index('ix_users_ldap_username', table_name='users')
|
||||
|
||||
if _index_exists(conn, 'ix_users_ldap_dn'):
|
||||
op.drop_index('ix_users_ldap_dn', table_name='users')
|
||||
|
||||
if _column_exists(conn, 'users', 'ldap_username'):
|
||||
op.drop_column('users', 'ldap_username')
|
||||
|
||||
if _column_exists(conn, 'users', 'ldap_dn'):
|
||||
op.drop_column('users', 'ldap_dn')
|
||||
|
||||
if _column_exists(conn, 'users', 'auth_source'):
|
||||
op.drop_column('users', 'auth_source')
|
||||
|
||||
# 3. 删除 authsource 枚举类型(幂等)
|
||||
# 注意:不使用 CASCADE,因为此时所有依赖应该已被删除
|
||||
if _type_exists(conn, 'authsource'):
|
||||
conn.execute(text("DROP TYPE authsource"))
|
||||
@@ -0,0 +1,131 @@
|
||||
"""add_management_tokens_table
|
||||
|
||||
Revision ID: ad55f1d008b7
|
||||
Revises: c3d4e5f6g7h8
|
||||
Create Date: 2026-01-06 15:24:10.660394+00:00
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy import inspect
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'ad55f1d008b7'
|
||||
down_revision = 'c3d4e5f6g7h8'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def table_exists(table_name: str) -> bool:
|
||||
"""检查表是否存在"""
|
||||
conn = op.get_bind()
|
||||
inspector = inspect(conn)
|
||||
return table_name in inspector.get_table_names()
|
||||
|
||||
|
||||
def index_exists(table_name: str, index_name: str) -> bool:
|
||||
"""检查索引是否存在"""
|
||||
conn = op.get_bind()
|
||||
inspector = inspect(conn)
|
||||
try:
|
||||
indexes = inspector.get_indexes(table_name)
|
||||
return any(idx["name"] == index_name for idx in indexes)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def constraint_exists(table_name: str, constraint_name: str) -> bool:
|
||||
"""检查约束是否存在"""
|
||||
conn = op.get_bind()
|
||||
inspector = inspect(conn)
|
||||
try:
|
||||
constraints = inspector.get_unique_constraints(table_name)
|
||||
if any(c["name"] == constraint_name for c in constraints):
|
||||
return True
|
||||
# 也检查 check 约束
|
||||
check_constraints = inspector.get_check_constraints(table_name)
|
||||
if any(c["name"] == constraint_name for c in check_constraints):
|
||||
return True
|
||||
return False
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""应用迁移:创建 management_tokens 表"""
|
||||
# 幂等性检查
|
||||
if table_exists("management_tokens"):
|
||||
# 表已存在,检查是否需要添加约束
|
||||
if not constraint_exists("management_tokens", "uq_management_tokens_user_name"):
|
||||
op.create_unique_constraint(
|
||||
"uq_management_tokens_user_name",
|
||||
"management_tokens",
|
||||
["user_id", "name"],
|
||||
)
|
||||
# 添加 IP 白名单非空检查约束
|
||||
if not constraint_exists("management_tokens", "check_allowed_ips_not_empty"):
|
||||
op.create_check_constraint(
|
||||
"check_allowed_ips_not_empty",
|
||||
"management_tokens",
|
||||
"allowed_ips IS NULL OR allowed_ips::text = 'null' OR json_array_length(allowed_ips) > 0",
|
||||
)
|
||||
return
|
||||
|
||||
op.create_table('management_tokens',
|
||||
sa.Column('id', sa.String(length=36), nullable=False),
|
||||
sa.Column('user_id', sa.String(length=36), nullable=False),
|
||||
sa.Column('token_hash', sa.String(length=64), nullable=False),
|
||||
sa.Column('token_prefix', sa.String(length=12), nullable=True),
|
||||
sa.Column('name', sa.String(length=100), nullable=False),
|
||||
sa.Column('description', sa.Text(), nullable=True),
|
||||
sa.Column('allowed_ips', sa.JSON(), nullable=True),
|
||||
sa.Column('expires_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('last_used_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('last_used_ip', sa.String(length=45), nullable=True),
|
||||
sa.Column('usage_count', sa.Integer(), server_default='0', nullable=False),
|
||||
sa.Column('is_active', sa.Boolean(), server_default='true', nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index('idx_management_tokens_is_active', 'management_tokens', ['is_active'], unique=False)
|
||||
op.create_index('idx_management_tokens_user_id', 'management_tokens', ['user_id'], unique=False)
|
||||
op.create_index(op.f('ix_management_tokens_token_hash'), 'management_tokens', ['token_hash'], unique=True)
|
||||
# 添加用户名称唯一约束
|
||||
op.create_unique_constraint(
|
||||
"uq_management_tokens_user_name",
|
||||
"management_tokens",
|
||||
["user_id", "name"],
|
||||
)
|
||||
# 添加 IP 白名单非空检查约束
|
||||
# 注意:JSON 类型的 NULL 可能被序列化为 JSON 'null',需要同时处理
|
||||
op.create_check_constraint(
|
||||
"check_allowed_ips_not_empty",
|
||||
"management_tokens",
|
||||
"allowed_ips IS NULL OR allowed_ips::text = 'null' OR json_array_length(allowed_ips) > 0",
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""回滚迁移:删除 management_tokens 表"""
|
||||
# 幂等性检查
|
||||
if not table_exists("management_tokens"):
|
||||
return
|
||||
|
||||
# 删除约束
|
||||
if constraint_exists("management_tokens", "check_allowed_ips_not_empty"):
|
||||
op.drop_constraint("check_allowed_ips_not_empty", "management_tokens", type_="check")
|
||||
if constraint_exists("management_tokens", "uq_management_tokens_user_name"):
|
||||
op.drop_constraint("uq_management_tokens_user_name", "management_tokens", type_="unique")
|
||||
|
||||
# 删除索引
|
||||
if index_exists("management_tokens", "ix_management_tokens_token_hash"):
|
||||
op.drop_index(op.f('ix_management_tokens_token_hash'), table_name='management_tokens')
|
||||
if index_exists("management_tokens", "idx_management_tokens_user_id"):
|
||||
op.drop_index('idx_management_tokens_user_id', table_name='management_tokens')
|
||||
if index_exists("management_tokens", "idx_management_tokens_is_active"):
|
||||
op.drop_index('idx_management_tokens_is_active', table_name='management_tokens')
|
||||
|
||||
# 删除表
|
||||
op.drop_table('management_tokens')
|
||||
@@ -0,0 +1,73 @@
|
||||
"""cleanup ambiguous database fields
|
||||
|
||||
Revision ID: 02a45b66b7c4
|
||||
Revises: ad55f1d008b7
|
||||
Create Date: 2026-01-07 11:20:12.684426+00:00
|
||||
|
||||
变更内容:
|
||||
1. users 表:重命名 allowed_endpoints 为 allowed_api_formats(修正历史命名错误)
|
||||
2. api_keys 表:删除 allowed_endpoints 字段(未使用的功能)
|
||||
3. providers 表:删除 rate_limit 字段(与 rpm_limit 功能重复,且未使用)
|
||||
4. usage 表:重命名 provider 为 provider_name(避免与 provider_id 外键混淆)
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy import inspect
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '02a45b66b7c4'
|
||||
down_revision = 'ad55f1d008b7'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _column_exists(table_name: str, column_name: str) -> bool:
|
||||
"""检查列是否存在"""
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
columns = [col['name'] for col in inspector.get_columns(table_name)]
|
||||
return column_name in columns
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""
|
||||
1. users.allowed_endpoints -> allowed_api_formats(重命名)
|
||||
2. api_keys.allowed_endpoints 删除
|
||||
3. providers.rate_limit 删除(与 rpm_limit 重复)
|
||||
4. usage.provider -> provider_name(重命名)
|
||||
"""
|
||||
# 1. users 表:重命名 allowed_endpoints 为 allowed_api_formats
|
||||
if _column_exists('users', 'allowed_endpoints'):
|
||||
op.alter_column('users', 'allowed_endpoints', new_column_name='allowed_api_formats')
|
||||
|
||||
# 2. api_keys 表:删除 allowed_endpoints 字段
|
||||
if _column_exists('api_keys', 'allowed_endpoints'):
|
||||
op.drop_column('api_keys', 'allowed_endpoints')
|
||||
|
||||
# 3. providers 表:删除 rate_limit 字段(与 rpm_limit 功能重复)
|
||||
if _column_exists('providers', 'rate_limit'):
|
||||
op.drop_column('providers', 'rate_limit')
|
||||
|
||||
# 4. usage 表:重命名 provider 为 provider_name
|
||||
if _column_exists('usage', 'provider'):
|
||||
op.alter_column('usage', 'provider', new_column_name='provider_name')
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""回滚:恢复原字段"""
|
||||
# 4. usage 表:将 provider_name 改回 provider
|
||||
if _column_exists('usage', 'provider_name'):
|
||||
op.alter_column('usage', 'provider_name', new_column_name='provider')
|
||||
|
||||
# 3. providers 表:恢复 rate_limit 字段
|
||||
if not _column_exists('providers', 'rate_limit'):
|
||||
op.add_column('providers', sa.Column('rate_limit', sa.Integer(), nullable=True))
|
||||
|
||||
# 2. api_keys 表:恢复 allowed_endpoints 字段
|
||||
if not _column_exists('api_keys', 'allowed_endpoints'):
|
||||
op.add_column('api_keys', sa.Column('allowed_endpoints', sa.JSON(), nullable=True))
|
||||
|
||||
# 1. users 表:将 allowed_api_formats 改回 allowed_endpoints
|
||||
if _column_exists('users', 'allowed_api_formats'):
|
||||
op.alter_column('users', 'allowed_api_formats', new_column_name='allowed_endpoints')
|
||||
@@ -0,0 +1,604 @@
|
||||
"""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
|
||||
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
from sqlalchemy.exc import ProgrammingError
|
||||
|
||||
from alembic import op
|
||||
|
||||
# 配置日志
|
||||
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 (bypasses inspector cache)"""
|
||||
bind = op.get_bind()
|
||||
result = bind.execute(
|
||||
sa.text(
|
||||
"SELECT 1 FROM information_schema.columns "
|
||||
"WHERE table_name = :table AND column_name = :col"
|
||||
),
|
||||
{"table": table_name, "col": column_name},
|
||||
)
|
||||
return result.scalar() is not None
|
||||
|
||||
|
||||
def _constraint_exists(table_name: str, constraint_name: str) -> bool:
|
||||
"""Check if a constraint exists (bypasses inspector cache)"""
|
||||
bind = op.get_bind()
|
||||
result = bind.execute(
|
||||
sa.text(
|
||||
"SELECT 1 FROM information_schema.table_constraints "
|
||||
"WHERE table_name = :table AND constraint_name = :name"
|
||||
),
|
||||
{"table": table_name, "name": constraint_name},
|
||||
)
|
||||
return result.scalar() is not None
|
||||
|
||||
|
||||
def _index_exists(table_name: str, index_name: str) -> bool:
|
||||
"""Check if an index exists (bypasses inspector cache)"""
|
||||
bind = op.get_bind()
|
||||
result = bind.execute(
|
||||
sa.text("SELECT 1 FROM pg_indexes WHERE indexname = :name"),
|
||||
{"name": index_name},
|
||||
)
|
||||
return result.scalar() is not None
|
||||
|
||||
|
||||
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"):
|
||||
conn = op.get_bind()
|
||||
conn.execute(sa.text("SAVEPOINT sp_add_provider_id"))
|
||||
try:
|
||||
op.add_column(
|
||||
"provider_api_keys", sa.Column("provider_id", sa.String(36), nullable=True)
|
||||
)
|
||||
conn.execute(sa.text("RELEASE SAVEPOINT sp_add_provider_id"))
|
||||
except ProgrammingError as exc:
|
||||
if getattr(getattr(exc, "orig", None), "pgcode", None) == "42701":
|
||||
conn.execute(sa.text("ROLLBACK TO SAVEPOINT sp_add_provider_id"))
|
||||
alembic_logger.warning("provider_api_keys.provider_id already exists; skipping add")
|
||||
else:
|
||||
conn.execute(sa.text("ROLLBACK TO SAVEPOINT sp_add_provider_id"))
|
||||
raise
|
||||
|
||||
# 数据迁移:从 endpoint 获取 provider_id(如果 endpoint_id 仍存在)
|
||||
if _column_exists("provider_api_keys", "endpoint_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(动态构建 SQL,仅引用存在的列)
|
||||
ep_has_timeout = _column_exists("provider_endpoints", "timeout")
|
||||
ep_has_max_retries = _column_exists("provider_endpoints", "max_retries")
|
||||
ep_has_proxy = _column_exists("provider_endpoints", "proxy")
|
||||
|
||||
set_clauses = []
|
||||
if _column_exists("providers", "timeout"):
|
||||
if ep_has_timeout:
|
||||
set_clauses.append("""
|
||||
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
|
||||
)""")
|
||||
else:
|
||||
set_clauses.append("timeout = COALESCE(p.timeout, 300)")
|
||||
|
||||
if _column_exists("providers", "max_retries"):
|
||||
if ep_has_max_retries:
|
||||
set_clauses.append("""
|
||||
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
|
||||
)""")
|
||||
else:
|
||||
set_clauses.append("max_retries = COALESCE(p.max_retries, 2)")
|
||||
|
||||
if _column_exists("providers", "proxy") and ep_has_proxy:
|
||||
set_clauses.append("""
|
||||
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)
|
||||
)""")
|
||||
|
||||
if set_clauses:
|
||||
where_parts = []
|
||||
if _column_exists("providers", "timeout"):
|
||||
where_parts.append("p.timeout IS NULL")
|
||||
if _column_exists("providers", "max_retries"):
|
||||
where_parts.append("p.max_retries IS NULL")
|
||||
where_clause = " OR ".join(where_parts) if where_parts else "TRUE"
|
||||
sql = "UPDATE providers p SET " + ", ".join(set_clauses) + " WHERE " + where_clause
|
||||
op.execute(sql)
|
||||
|
||||
# ========== 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"):
|
||||
# 查找 endpoint_id 上的外键并删除(用 savepoint 保护,避免事务中止)
|
||||
conn = op.get_bind()
|
||||
fk_rows = conn.execute(
|
||||
sa.text(
|
||||
"SELECT con.conname FROM pg_constraint con "
|
||||
"JOIN pg_attribute att ON att.attnum = ANY(con.conkey) "
|
||||
" AND att.attrelid = con.conrelid "
|
||||
"WHERE con.conrelid = 'provider_api_keys'::regclass "
|
||||
" AND con.contype = 'f' AND att.attname = 'endpoint_id'"
|
||||
)
|
||||
).fetchall()
|
||||
for (fk_name,) in fk_rows:
|
||||
conn.execute(sa.text(f"SAVEPOINT sp_drop_fk_{fk_name}"))
|
||||
try:
|
||||
op.drop_constraint(fk_name, "provider_api_keys", type_="foreignkey")
|
||||
conn.execute(sa.text(f"RELEASE SAVEPOINT sp_drop_fk_{fk_name}"))
|
||||
except Exception:
|
||||
conn.execute(sa.text(f"ROLLBACK TO SAVEPOINT sp_drop_fk_{fk_name}"))
|
||||
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 _column_exists("providers", "name") and not _column_exists("providers", "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")
|
||||
|
||||
if not _column_exists("providers", "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)
|
||||
if not _index_exists("providers", "ix_providers_name"):
|
||||
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)")
|
||||
@@ -0,0 +1,95 @@
|
||||
"""add auto_fetch_models and locked_models to provider_api_keys
|
||||
|
||||
Revision ID: e4ebe3233b40
|
||||
Revises: m4n5o6p7q8r9
|
||||
Create Date: 2026-01-13 17:59:53.119479+00:00
|
||||
|
||||
为 provider_api_keys 表添加自动获取模型相关字段:
|
||||
1. auto_fetch_models: 是否启用自动获取模型
|
||||
2. last_models_fetch_at: 最后获取时间
|
||||
3. last_models_fetch_error: 最后获取错误信息
|
||||
4. locked_models: 被锁定的模型列表(刷新时不会被删除)
|
||||
|
||||
注意: downgrade 操作会永久删除 auto_fetch_models 配置和 locked_models 数据
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy import inspect
|
||||
|
||||
|
||||
def _index_exists(index_name: str) -> bool:
|
||||
"""Check if an index exists"""
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
indexes = inspector.get_indexes("provider_api_keys")
|
||||
return any(idx["name"] == index_name for idx in indexes)
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'e4ebe3233b40'
|
||||
down_revision = 'm4n5o6p7q8r9'
|
||||
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 upgrade() -> None:
|
||||
"""添加自动获取模型相关字段"""
|
||||
if not _column_exists("provider_api_keys", "auto_fetch_models"):
|
||||
op.add_column(
|
||||
"provider_api_keys",
|
||||
sa.Column("auto_fetch_models", sa.Boolean(), nullable=False, server_default="false"),
|
||||
)
|
||||
|
||||
if not _column_exists("provider_api_keys", "last_models_fetch_at"):
|
||||
op.add_column(
|
||||
"provider_api_keys",
|
||||
sa.Column("last_models_fetch_at", sa.DateTime(timezone=True), nullable=True),
|
||||
)
|
||||
|
||||
if not _column_exists("provider_api_keys", "last_models_fetch_error"):
|
||||
op.add_column(
|
||||
"provider_api_keys",
|
||||
sa.Column("last_models_fetch_error", sa.Text(), nullable=True),
|
||||
)
|
||||
|
||||
if not _column_exists("provider_api_keys", "locked_models"):
|
||||
op.add_column(
|
||||
"provider_api_keys",
|
||||
sa.Column("locked_models", sa.JSON(), nullable=True),
|
||||
)
|
||||
|
||||
# 添加复合索引以优化调度器查询
|
||||
if not _index_exists("ix_provider_api_keys_auto_fetch_active"):
|
||||
op.create_index(
|
||||
"ix_provider_api_keys_auto_fetch_active",
|
||||
"provider_api_keys",
|
||||
["auto_fetch_models", "is_active"],
|
||||
postgresql_where=sa.text("auto_fetch_models = true AND is_active = true"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""移除自动获取模型相关字段"""
|
||||
# 先删除索引
|
||||
if _index_exists("ix_provider_api_keys_auto_fetch_active"):
|
||||
op.drop_index("ix_provider_api_keys_auto_fetch_active", table_name="provider_api_keys")
|
||||
|
||||
if _column_exists("provider_api_keys", "locked_models"):
|
||||
op.drop_column("provider_api_keys", "locked_models")
|
||||
|
||||
if _column_exists("provider_api_keys", "last_models_fetch_error"):
|
||||
op.drop_column("provider_api_keys", "last_models_fetch_error")
|
||||
|
||||
if _column_exists("provider_api_keys", "last_models_fetch_at"):
|
||||
op.drop_column("provider_api_keys", "last_models_fetch_at")
|
||||
|
||||
if _column_exists("provider_api_keys", "auto_fetch_models"):
|
||||
op.drop_column("provider_api_keys", "auto_fetch_models")
|
||||
@@ -0,0 +1,104 @@
|
||||
"""add header_rules to provider_endpoints and is_locked to api_keys
|
||||
|
||||
Revision ID: 6d579000e511
|
||||
Revises: e4ebe3233b40
|
||||
Create Date: 2026-01-15 23:00:00.000000+00:00
|
||||
|
||||
变更:
|
||||
1. provider_endpoints 表: 添加 header_rules 字段,迁移 headers 数据
|
||||
2. api_keys 表: 添加 is_locked 字段(管理员锁定标志)
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects.postgresql import JSON
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '6d579000e511'
|
||||
down_revision = 'e4ebe3233b40'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _column_exists(connection, table: str, column: str) -> bool:
|
||||
"""检查列是否存在"""
|
||||
result = connection.execute(
|
||||
sa.text("""
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = :table AND column_name = :column
|
||||
"""),
|
||||
{"table": table, "column": column}
|
||||
)
|
||||
return result.fetchone() is not None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""添加 header_rules 字段并迁移现有 headers 数据;添加 is_locked 字段"""
|
||||
connection = op.get_bind()
|
||||
|
||||
# ========== provider_endpoints.header_rules ==========
|
||||
# 1. 添加 header_rules 列(幂等)
|
||||
if not _column_exists(connection, 'provider_endpoints', 'header_rules'):
|
||||
op.add_column('provider_endpoints', sa.Column('header_rules', JSON, nullable=True))
|
||||
|
||||
# 2. 批量迁移:headers -> header_rules
|
||||
# 使用纯 SQL 将 {"k1":"v1", "k2":"v2"} 转换为 [{"action":"set","key":"k1","value":"v1"}, ...]
|
||||
if _column_exists(connection, 'provider_endpoints', 'headers'):
|
||||
connection.execute(
|
||||
sa.text("""
|
||||
UPDATE provider_endpoints
|
||||
SET header_rules = (
|
||||
SELECT jsonb_agg(
|
||||
jsonb_build_object('action', 'set', 'key', key, 'value', value)
|
||||
)
|
||||
FROM jsonb_each_text(headers::jsonb)
|
||||
)
|
||||
WHERE headers IS NOT NULL
|
||||
AND headers::text != '{}'
|
||||
AND jsonb_typeof(headers::jsonb) = 'object'
|
||||
AND header_rules IS NULL
|
||||
""")
|
||||
)
|
||||
|
||||
# 3. 删除旧列
|
||||
op.drop_column('provider_endpoints', 'headers')
|
||||
|
||||
# ========== api_keys.is_locked ==========
|
||||
if not _column_exists(connection, 'api_keys', 'is_locked'):
|
||||
op.add_column(
|
||||
'api_keys',
|
||||
sa.Column('is_locked', sa.Boolean(), nullable=False, server_default='false')
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""移除 header_rules 字段,恢复 headers 字段;移除 is_locked 字段"""
|
||||
connection = op.get_bind()
|
||||
|
||||
# ========== api_keys.is_locked ==========
|
||||
if _column_exists(connection, 'api_keys', 'is_locked'):
|
||||
op.drop_column('api_keys', 'is_locked')
|
||||
|
||||
# ========== provider_endpoints.header_rules ==========
|
||||
# 1. 添加 headers 列(幂等)
|
||||
if not _column_exists(connection, 'provider_endpoints', 'headers'):
|
||||
op.add_column('provider_endpoints', sa.Column('headers', JSON, nullable=True))
|
||||
|
||||
# 2. 批量迁移:header_rules -> headers(仅提取 set 操作)
|
||||
if _column_exists(connection, 'provider_endpoints', 'header_rules'):
|
||||
connection.execute(
|
||||
sa.text("""
|
||||
UPDATE provider_endpoints
|
||||
SET headers = (
|
||||
SELECT jsonb_object_agg(rule->>'key', rule->>'value')
|
||||
FROM jsonb_array_elements(header_rules::jsonb) AS rule
|
||||
WHERE rule->>'action' = 'set'
|
||||
AND rule->>'key' IS NOT NULL
|
||||
)
|
||||
WHERE header_rules IS NOT NULL
|
||||
AND jsonb_typeof(header_rules::jsonb) = 'array'
|
||||
AND jsonb_array_length(header_rules::jsonb) > 0
|
||||
""")
|
||||
)
|
||||
|
||||
# 3. 删除 header_rules 列
|
||||
op.drop_column('provider_endpoints', 'header_rules')
|
||||
@@ -0,0 +1,127 @@
|
||||
"""add global_priority_by_format and remove deprecated fields
|
||||
|
||||
Revision ID: ddd59cdf0349
|
||||
Revises: 6d579000e511
|
||||
Create Date: 2026-01-16 12:00:00.000000+00:00
|
||||
|
||||
变更:
|
||||
1. provider_api_keys 表: 添加 global_priority_by_format 字段(按 API 格式的全局优先级)
|
||||
2. 迁移现有 global_priority 数据到新字段
|
||||
3. 删除已废弃的 global_priority 字段
|
||||
4. 删除已废弃的 rate_multiplier 字段(已被 rate_multipliers 替代)
|
||||
5. 删除已废弃的 providers.timeout 字段(由环境变量控制)
|
||||
6. 删除已废弃的 provider_endpoints.timeout 字段(由环境变量控制)
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects.postgresql import JSON
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'ddd59cdf0349'
|
||||
down_revision = '6d579000e511'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _column_exists(connection, table: str, column: str) -> bool:
|
||||
"""检查列是否存在"""
|
||||
result = connection.execute(
|
||||
sa.text("""
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = :table AND column_name = :column
|
||||
"""),
|
||||
{"table": table, "column": column}
|
||||
)
|
||||
return result.fetchone() is not None
|
||||
|
||||
|
||||
def upgrade():
|
||||
connection = op.get_bind()
|
||||
|
||||
# 1. 添加 global_priority_by_format 字段
|
||||
if not _column_exists(connection, 'provider_api_keys', 'global_priority_by_format'):
|
||||
op.add_column(
|
||||
'provider_api_keys',
|
||||
sa.Column('global_priority_by_format', JSON, nullable=True)
|
||||
)
|
||||
|
||||
# 2. 迁移现有 global_priority 数据到新字段
|
||||
# 对于有 global_priority 的 Key,将其值应用到所有支持的 api_formats
|
||||
if _column_exists(connection, 'provider_api_keys', 'global_priority'):
|
||||
# 将 JSON 数组转换为 text[] 后使用 unnest
|
||||
connection.execute(sa.text("""
|
||||
UPDATE provider_api_keys
|
||||
SET global_priority_by_format = (
|
||||
SELECT jsonb_object_agg(format, global_priority)
|
||||
FROM jsonb_array_elements_text(api_formats::jsonb) AS format
|
||||
)
|
||||
WHERE global_priority IS NOT NULL
|
||||
AND api_formats IS NOT NULL
|
||||
AND jsonb_array_length(api_formats::jsonb) > 0
|
||||
AND global_priority_by_format IS NULL
|
||||
"""))
|
||||
|
||||
# 3. 删除 global_priority 字段
|
||||
op.drop_column('provider_api_keys', 'global_priority')
|
||||
|
||||
# 4. 删除 rate_multiplier 字段(已被 rate_multipliers 替代)
|
||||
if _column_exists(connection, 'provider_api_keys', 'rate_multiplier'):
|
||||
op.drop_column('provider_api_keys', 'rate_multiplier')
|
||||
|
||||
# 5. 删除 providers.timeout 字段(由环境变量控制)
|
||||
if _column_exists(connection, 'providers', 'timeout'):
|
||||
op.drop_column('providers', 'timeout')
|
||||
|
||||
# 6. 删除 provider_endpoints.timeout 字段(由环境变量控制)
|
||||
if _column_exists(connection, 'provider_endpoints', 'timeout'):
|
||||
op.drop_column('provider_endpoints', 'timeout')
|
||||
|
||||
|
||||
def downgrade():
|
||||
connection = op.get_bind()
|
||||
|
||||
# 1. 恢复 rate_multiplier 字段
|
||||
if not _column_exists(connection, 'provider_api_keys', 'rate_multiplier'):
|
||||
op.add_column(
|
||||
'provider_api_keys',
|
||||
sa.Column('rate_multiplier', sa.Float, nullable=False, server_default='1.0')
|
||||
)
|
||||
|
||||
# 2. 恢复 global_priority 字段并迁移数据
|
||||
if not _column_exists(connection, 'provider_api_keys', 'global_priority'):
|
||||
op.add_column(
|
||||
'provider_api_keys',
|
||||
sa.Column('global_priority', sa.Integer, nullable=True)
|
||||
)
|
||||
|
||||
# 从 global_priority_by_format 迁移数据(取第一个格式的优先级值)
|
||||
if _column_exists(connection, 'provider_api_keys', 'global_priority_by_format'):
|
||||
connection.execute(sa.text("""
|
||||
UPDATE provider_api_keys
|
||||
SET global_priority = (
|
||||
SELECT (value::text)::integer
|
||||
FROM jsonb_each(global_priority_by_format::jsonb)
|
||||
LIMIT 1
|
||||
)
|
||||
WHERE global_priority_by_format IS NOT NULL
|
||||
AND jsonb_typeof(global_priority_by_format::jsonb) = 'object'
|
||||
AND global_priority IS NULL
|
||||
"""))
|
||||
|
||||
# 3. 删除 global_priority_by_format 字段
|
||||
if _column_exists(connection, 'provider_api_keys', 'global_priority_by_format'):
|
||||
op.drop_column('provider_api_keys', 'global_priority_by_format')
|
||||
|
||||
# 4. 恢复 providers.timeout 字段
|
||||
if not _column_exists(connection, 'providers', 'timeout'):
|
||||
op.add_column(
|
||||
'providers',
|
||||
sa.Column('timeout', sa.Integer, nullable=True, server_default='300')
|
||||
)
|
||||
|
||||
# 5. 恢复 provider_endpoints.timeout 字段
|
||||
if not _column_exists(connection, 'provider_endpoints', 'timeout'):
|
||||
op.add_column(
|
||||
'provider_endpoints',
|
||||
sa.Column('timeout', sa.Integer, nullable=True, server_default='300')
|
||||
)
|
||||
@@ -0,0 +1,223 @@
|
||||
"""make users email/password nullable add email_verified and oauth tables
|
||||
|
||||
Revision ID: 33e347f97c0c
|
||||
Revises: ddd59cdf0349
|
||||
Create Date: 2026-01-18 11:18:15.940559+00:00
|
||||
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy import inspect
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "33e347f97c0c"
|
||||
down_revision = "ddd59cdf0349"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def column_exists(table_name: str, column_name: str) -> bool:
|
||||
"""检查列是否存在"""
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
columns = [col["name"] for col in inspector.get_columns(table_name)]
|
||||
return column_name in columns
|
||||
|
||||
|
||||
def table_exists(table_name: str) -> bool:
|
||||
"""检查表是否存在"""
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
return table_name in inspector.get_table_names()
|
||||
|
||||
|
||||
def column_is_nullable(table_name: str, column_name: str) -> bool:
|
||||
"""检查列是否允许 NULL"""
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
for col in inspector.get_columns(table_name):
|
||||
if col["name"] == column_name:
|
||||
return col["nullable"]
|
||||
return False
|
||||
|
||||
|
||||
def enum_value_exists(enum_name: str, value: str) -> bool:
|
||||
"""检查 PostgreSQL ENUM 是否包含指定值"""
|
||||
bind = op.get_bind()
|
||||
if bind.dialect.name != "postgresql":
|
||||
return True # 非 PostgreSQL 跳过检查
|
||||
result = bind.execute(
|
||||
sa.text(
|
||||
"SELECT 1 FROM pg_enum WHERE enumlabel = :value "
|
||||
"AND enumtypid = (SELECT oid FROM pg_type WHERE typname = :enum_name)"
|
||||
),
|
||||
{"value": value, "enum_name": enum_name},
|
||||
).first()
|
||||
return result is not None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""应用迁移:升级到新版本"""
|
||||
bind = op.get_bind()
|
||||
|
||||
# ========== Part 1: users 表修改 ==========
|
||||
|
||||
# 1) 新增 email_verified
|
||||
if not column_exists("users", "email_verified"):
|
||||
op.add_column("users", sa.Column("email_verified", sa.Boolean(), nullable=True))
|
||||
# 历史数据回填:已有邮箱的用户默认视为已验证
|
||||
op.execute(sa.text("UPDATE users SET email_verified = true WHERE email IS NOT NULL"))
|
||||
op.execute(sa.text("UPDATE users SET email_verified = false WHERE email IS NULL"))
|
||||
# 收紧约束
|
||||
op.alter_column("users", "email_verified", existing_type=sa.Boolean(), nullable=False)
|
||||
|
||||
# 2) email 放宽为可空
|
||||
if not column_is_nullable("users", "email"):
|
||||
op.alter_column(
|
||||
"users",
|
||||
"email",
|
||||
existing_type=sa.String(length=255),
|
||||
nullable=True,
|
||||
)
|
||||
|
||||
# 3) password_hash 放宽为可空
|
||||
if not column_is_nullable("users", "password_hash"):
|
||||
op.alter_column(
|
||||
"users",
|
||||
"password_hash",
|
||||
existing_type=sa.String(length=255),
|
||||
nullable=True,
|
||||
)
|
||||
|
||||
# ========== Part 2: OAuth 相关 ==========
|
||||
|
||||
# 4) 扩展 authsource enum
|
||||
if bind.dialect.name == "postgresql" and not enum_value_exists("authsource", "oauth"):
|
||||
ctx = op.get_context()
|
||||
with ctx.autocommit_block():
|
||||
op.execute("ALTER TYPE authsource ADD VALUE IF NOT EXISTS 'oauth'")
|
||||
|
||||
# 5) OAuth provider 配置表
|
||||
if not table_exists("oauth_providers"):
|
||||
op.create_table(
|
||||
"oauth_providers",
|
||||
sa.Column("provider_type", sa.String(length=50), primary_key=True),
|
||||
sa.Column("display_name", sa.String(length=100), nullable=False),
|
||||
sa.Column("client_id", sa.String(length=255), nullable=False),
|
||||
sa.Column("client_secret_encrypted", sa.Text(), nullable=True),
|
||||
sa.Column("authorization_url_override", sa.String(length=500), nullable=True),
|
||||
sa.Column("token_url_override", sa.String(length=500), nullable=True),
|
||||
sa.Column("userinfo_url_override", sa.String(length=500), nullable=True),
|
||||
sa.Column("scopes", sa.JSON(), nullable=True),
|
||||
sa.Column("redirect_uri", sa.String(length=500), nullable=False),
|
||||
sa.Column("frontend_callback_url", sa.String(length=500), nullable=False),
|
||||
sa.Column("attribute_mapping", sa.JSON(), nullable=True),
|
||||
sa.Column("extra_config", sa.JSON(), nullable=True),
|
||||
sa.Column(
|
||||
"is_enabled", sa.Boolean(), nullable=False, server_default=sa.text("false")
|
||||
),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=sa.text("CURRENT_TIMESTAMP"),
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at",
|
||||
sa.DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=sa.text("CURRENT_TIMESTAMP"),
|
||||
),
|
||||
)
|
||||
|
||||
# 6) 用户 OAuth 绑定关系表
|
||||
if not table_exists("user_oauth_links"):
|
||||
op.create_table(
|
||||
"user_oauth_links",
|
||||
sa.Column("id", sa.String(length=36), primary_key=True),
|
||||
sa.Column(
|
||||
"user_id",
|
||||
sa.String(length=36),
|
||||
sa.ForeignKey("users.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"provider_type",
|
||||
sa.String(length=50),
|
||||
sa.ForeignKey("oauth_providers.provider_type", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("provider_user_id", sa.String(length=255), nullable=False),
|
||||
sa.Column("provider_username", sa.String(length=255), nullable=True),
|
||||
sa.Column("provider_email", sa.String(length=255), nullable=True),
|
||||
sa.Column("extra_data", sa.JSON(), nullable=True),
|
||||
sa.Column(
|
||||
"linked_at",
|
||||
sa.DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=sa.text("CURRENT_TIMESTAMP"),
|
||||
),
|
||||
sa.Column("last_login_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.UniqueConstraint(
|
||||
"provider_type", "provider_user_id", name="uq_oauth_provider_user"
|
||||
),
|
||||
sa.UniqueConstraint("user_id", "provider_type", name="uq_user_oauth_provider"),
|
||||
)
|
||||
op.create_index("ix_user_oauth_links_user_id", "user_oauth_links", ["user_id"])
|
||||
op.create_index(
|
||||
"ix_user_oauth_links_provider_type", "user_oauth_links", ["provider_type"]
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""回滚迁移:降级到旧版本"""
|
||||
bind = op.get_bind()
|
||||
|
||||
# ========== Part 2: OAuth 相关(先删除,因为有外键依赖) ==========
|
||||
|
||||
if table_exists("user_oauth_links"):
|
||||
op.drop_index("ix_user_oauth_links_provider_type", table_name="user_oauth_links")
|
||||
op.drop_index("ix_user_oauth_links_user_id", table_name="user_oauth_links")
|
||||
op.drop_table("user_oauth_links")
|
||||
|
||||
if table_exists("oauth_providers"):
|
||||
op.drop_table("oauth_providers")
|
||||
|
||||
# 注意:Postgres 不支持从 ENUM 删除值,authsource 不回退
|
||||
|
||||
# ========== Part 1: users 表修改 ==========
|
||||
|
||||
# 降级前检查:避免把包含 NULL 的列强制改回 NOT NULL
|
||||
has_null_email = bind.execute(
|
||||
sa.text("SELECT 1 FROM users WHERE email IS NULL LIMIT 1")
|
||||
).first()
|
||||
if has_null_email:
|
||||
raise RuntimeError("Cannot downgrade: users.email contains NULL values")
|
||||
|
||||
has_null_password = bind.execute(
|
||||
sa.text("SELECT 1 FROM users WHERE password_hash IS NULL LIMIT 1")
|
||||
).first()
|
||||
if has_null_password:
|
||||
raise RuntimeError("Cannot downgrade: users.password_hash contains NULL values")
|
||||
|
||||
# 恢复 NOT NULL 约束
|
||||
if column_is_nullable("users", "email"):
|
||||
op.alter_column(
|
||||
"users",
|
||||
"email",
|
||||
existing_type=sa.String(length=255),
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
if column_is_nullable("users", "password_hash"):
|
||||
op.alter_column(
|
||||
"users",
|
||||
"password_hash",
|
||||
existing_type=sa.String(length=255),
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
if column_exists("users", "email_verified"):
|
||||
op.drop_column("users", "email_verified")
|
||||
@@ -0,0 +1,65 @@
|
||||
"""add_stats_daily_provider_table
|
||||
|
||||
Revision ID: c868729753ad
|
||||
Revises: 33e347f97c0c
|
||||
Create Date: 2026-01-19 05:19:49.634662+00:00
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy import inspect
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'c868729753ad'
|
||||
down_revision = '33e347f97c0c'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def table_exists(table_name: str) -> bool:
|
||||
"""检查表是否存在"""
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
return table_name in inspector.get_table_names()
|
||||
|
||||
|
||||
def index_exists(table_name: str, index_name: str) -> bool:
|
||||
"""检查索引是否存在"""
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
indexes = [idx['name'] for idx in inspector.get_indexes(table_name)]
|
||||
return index_name in indexes
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""应用迁移:升级到新版本"""
|
||||
if not table_exists('stats_daily_provider'):
|
||||
op.create_table(
|
||||
'stats_daily_provider',
|
||||
sa.Column('id', sa.String(length=36), nullable=False),
|
||||
sa.Column('date', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column('provider_name', sa.String(length=100), nullable=False),
|
||||
sa.Column('total_requests', sa.Integer(), nullable=False),
|
||||
sa.Column('input_tokens', sa.BigInteger(), nullable=False),
|
||||
sa.Column('output_tokens', sa.BigInteger(), nullable=False),
|
||||
sa.Column('cache_creation_tokens', sa.BigInteger(), nullable=False),
|
||||
sa.Column('cache_read_tokens', sa.BigInteger(), nullable=False),
|
||||
sa.Column('total_cost', sa.Float(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('date', 'provider_name', name='uq_stats_daily_provider')
|
||||
)
|
||||
op.create_index('idx_stats_daily_provider_date', 'stats_daily_provider', ['date'], unique=False)
|
||||
op.create_index('idx_stats_daily_provider_date_provider', 'stats_daily_provider', ['date', 'provider_name'], unique=False)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""回滚迁移:降级到旧版本"""
|
||||
if table_exists('stats_daily_provider'):
|
||||
if index_exists('stats_daily_provider', 'idx_stats_daily_provider_date_provider'):
|
||||
op.drop_index('idx_stats_daily_provider_date_provider', table_name='stats_daily_provider')
|
||||
if index_exists('stats_daily_provider', 'idx_stats_daily_provider_date'):
|
||||
op.drop_index('idx_stats_daily_provider_date', table_name='stats_daily_provider')
|
||||
op.drop_table('stats_daily_provider')
|
||||
@@ -0,0 +1,51 @@
|
||||
"""add_format_acceptance_config_to_provider_endpoints
|
||||
|
||||
Revision ID: 4b4c7b0df1a2
|
||||
Revises: c868729753ad
|
||||
Create Date: 2026-01-21 18:45:00+00:00
|
||||
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy import inspect
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "4b4c7b0df1a2"
|
||||
down_revision = "c868729753ad"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def table_exists(table_name: str) -> bool:
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
return table_name in inspector.get_table_names()
|
||||
|
||||
|
||||
def column_exists(table_name: str, column_name: str) -> bool:
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
columns = [col["name"] for col in inspector.get_columns(table_name)]
|
||||
return column_name in columns
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
if not table_exists("provider_endpoints"):
|
||||
return
|
||||
if column_exists("provider_endpoints", "format_acceptance_config"):
|
||||
return
|
||||
op.add_column(
|
||||
"provider_endpoints",
|
||||
sa.Column("format_acceptance_config", sa.JSON(), nullable=True),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
if not table_exists("provider_endpoints"):
|
||||
return
|
||||
if not column_exists("provider_endpoints", "format_acceptance_config"):
|
||||
return
|
||||
op.drop_column("provider_endpoints", "format_acceptance_config")
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
"""add_format_conversion_tracking_and_model_filter_patterns_and_provider_timeout
|
||||
|
||||
Revision ID: f7c8d9e0a1b2
|
||||
Revises: 4b4c7b0df1a2
|
||||
Create Date: 2026-01-27 10:00:00+00:00
|
||||
|
||||
Changes:
|
||||
1. usage 表: 添加 endpoint_api_format 和 has_format_conversion 字段
|
||||
2. provider_api_keys 表: 添加 model_include_patterns 和 model_exclude_patterns 字段
|
||||
- 支持通配符规则自动过滤从上游获取的模型列表
|
||||
- 包含规则和排除规则(支持 * 和 ? 通配符)
|
||||
3. providers 表: 添加 stream_first_byte_timeout 和 request_timeout 字段
|
||||
- 允许每个提供商单独配置超时时间
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy import inspect
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "f7c8d9e0a1b2"
|
||||
down_revision = "4b4c7b0df1a2"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def table_exists(table_name: str) -> bool:
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
return table_name in inspector.get_table_names()
|
||||
|
||||
|
||||
def column_exists(table_name: str, column_name: str) -> bool:
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
columns = [col["name"] for col in inspector.get_columns(table_name)]
|
||||
return column_name in columns
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# === usage 表: 格式转换追踪 ===
|
||||
if table_exists("usage"):
|
||||
# 添加 endpoint_api_format 字段(端点原生 API 格式)
|
||||
if not column_exists("usage", "endpoint_api_format"):
|
||||
op.add_column(
|
||||
"usage",
|
||||
sa.Column("endpoint_api_format", sa.String(50), nullable=True),
|
||||
)
|
||||
|
||||
# 添加 has_format_conversion 字段(是否发生了格式转换)
|
||||
if not column_exists("usage", "has_format_conversion"):
|
||||
op.add_column(
|
||||
"usage",
|
||||
sa.Column("has_format_conversion", sa.Boolean(), nullable=True, server_default="false"),
|
||||
)
|
||||
|
||||
# === provider_api_keys 表: 模型过滤规则 ===
|
||||
if table_exists("provider_api_keys"):
|
||||
# 添加 model_include_patterns 字段(包含规则,支持 * 和 ? 通配符)
|
||||
if not column_exists("provider_api_keys", "model_include_patterns"):
|
||||
op.add_column(
|
||||
"provider_api_keys",
|
||||
sa.Column("model_include_patterns", sa.JSON(), nullable=True),
|
||||
)
|
||||
|
||||
# 添加 model_exclude_patterns 字段(排除规则,支持 * 和 ? 通配符)
|
||||
if not column_exists("provider_api_keys", "model_exclude_patterns"):
|
||||
op.add_column(
|
||||
"provider_api_keys",
|
||||
sa.Column("model_exclude_patterns", sa.JSON(), nullable=True),
|
||||
)
|
||||
|
||||
# === providers 表: 超时配置 ===
|
||||
if table_exists("providers"):
|
||||
# 添加 stream_first_byte_timeout 字段(流式请求首字节超时)
|
||||
if not column_exists("providers", "stream_first_byte_timeout"):
|
||||
op.add_column(
|
||||
"providers",
|
||||
sa.Column("stream_first_byte_timeout", sa.Float(), nullable=True),
|
||||
)
|
||||
|
||||
# 添加 request_timeout 字段(非流式请求整体超时)
|
||||
if not column_exists("providers", "request_timeout"):
|
||||
op.add_column(
|
||||
"providers",
|
||||
sa.Column("request_timeout", sa.Float(), nullable=True),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# === providers 表: 移除超时配置 ===
|
||||
if table_exists("providers"):
|
||||
if column_exists("providers", "request_timeout"):
|
||||
op.drop_column("providers", "request_timeout")
|
||||
|
||||
if column_exists("providers", "stream_first_byte_timeout"):
|
||||
op.drop_column("providers", "stream_first_byte_timeout")
|
||||
|
||||
# === provider_api_keys 表: 移除模型过滤规则 ===
|
||||
if table_exists("provider_api_keys"):
|
||||
if column_exists("provider_api_keys", "model_exclude_patterns"):
|
||||
op.drop_column("provider_api_keys", "model_exclude_patterns")
|
||||
|
||||
if column_exists("provider_api_keys", "model_include_patterns"):
|
||||
op.drop_column("provider_api_keys", "model_include_patterns")
|
||||
|
||||
# === usage 表: 移除格式转换追踪 ===
|
||||
if table_exists("usage"):
|
||||
if column_exists("usage", "has_format_conversion"):
|
||||
op.drop_column("usage", "has_format_conversion")
|
||||
|
||||
if column_exists("usage", "endpoint_api_format"):
|
||||
op.drop_column("usage", "endpoint_api_format")
|
||||
@@ -0,0 +1,58 @@
|
||||
"""add_keep_priority_on_conversion_to_providers
|
||||
|
||||
Revision ID: 364680d1bc99
|
||||
Revises: f7c8d9e0a1b2
|
||||
Create Date: 2026-01-28 12:00:00+00:00
|
||||
|
||||
Changes:
|
||||
1. providers 表: 添加 keep_priority_on_conversion 字段
|
||||
- 格式转换时是否保持提供商原优先级
|
||||
- 默认 False:需要格式转换时,候选会被降级到不需要转换的候选之后
|
||||
- 设为 True:即使需要格式转换,也保持原优先级排名
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy import inspect
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "364680d1bc99"
|
||||
down_revision = "f7c8d9e0a1b2"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def table_exists(table_name: str) -> bool:
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
return table_name in inspector.get_table_names()
|
||||
|
||||
|
||||
def column_exists(table_name: str, column_name: str) -> bool:
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
columns = [col["name"] for col in inspector.get_columns(table_name)]
|
||||
return column_name in columns
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# === providers 表: 添加格式转换优先级保持配置 ===
|
||||
if table_exists("providers"):
|
||||
if not column_exists("providers", "keep_priority_on_conversion"):
|
||||
op.add_column(
|
||||
"providers",
|
||||
sa.Column(
|
||||
"keep_priority_on_conversion",
|
||||
sa.Boolean(),
|
||||
nullable=False,
|
||||
server_default="false",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# === providers 表: 移除格式转换优先级保持配置 ===
|
||||
if table_exists("providers"):
|
||||
if column_exists("providers", "keep_priority_on_conversion"):
|
||||
op.drop_column("providers", "keep_priority_on_conversion")
|
||||
@@ -0,0 +1,51 @@
|
||||
"""Add auth_type and auth_config fields to provider_api_keys table
|
||||
|
||||
Revision ID: 7f6f8065f517
|
||||
Revises: 364680d1bc99
|
||||
Create Date: 2026-01-30 10:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy import inspect
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "7f6f8065f517"
|
||||
down_revision: Union[str, None] = "364680d1bc99"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def column_exists(table_name: str, column_name: str) -> bool:
|
||||
"""检查列是否已存在"""
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
columns = [col["name"] for col in inspector.get_columns(table_name)]
|
||||
return column_name in columns
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# 添加 auth_type 字段,默认值为 "api_key"
|
||||
if not column_exists("provider_api_keys", "auth_type"):
|
||||
op.add_column(
|
||||
"provider_api_keys",
|
||||
sa.Column("auth_type", sa.String(20), nullable=False, server_default="api_key"),
|
||||
)
|
||||
|
||||
# 添加 auth_config 字段(Text,存储加密后的认证配置)
|
||||
if not column_exists("provider_api_keys", "auth_config"):
|
||||
op.add_column(
|
||||
"provider_api_keys",
|
||||
sa.Column("auth_config", sa.Text, nullable=True),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
if column_exists("provider_api_keys", "auth_config"):
|
||||
op.drop_column("provider_api_keys", "auth_config")
|
||||
|
||||
if column_exists("provider_api_keys", "auth_type"):
|
||||
op.drop_column("provider_api_keys", "auth_type")
|
||||
@@ -0,0 +1,112 @@
|
||||
"""Add video_tasks table
|
||||
|
||||
Revision ID: b6f1a2c5d8e9
|
||||
Revises: 7f6f8065f517
|
||||
Create Date: 2026-01-30 18:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy import inspect
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "b6f1a2c5d8e9"
|
||||
down_revision: Union[str, None] = "7f6f8065f517"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def table_exists(table_name: str) -> bool:
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
return table_name in inspector.get_table_names()
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
if table_exists("video_tasks"):
|
||||
return
|
||||
|
||||
op.create_table(
|
||||
"video_tasks",
|
||||
sa.Column("id", sa.String(36), primary_key=True),
|
||||
sa.Column("external_task_id", sa.String(200), nullable=True, index=False),
|
||||
sa.Column("user_id", sa.String(36), sa.ForeignKey("users.id"), nullable=False),
|
||||
sa.Column("api_key_id", sa.String(36), sa.ForeignKey("api_keys.id"), nullable=True),
|
||||
sa.Column("provider_id", sa.String(36), sa.ForeignKey("providers.id"), nullable=True),
|
||||
sa.Column(
|
||||
"endpoint_id", sa.String(36), sa.ForeignKey("provider_endpoints.id"), nullable=True
|
||||
),
|
||||
sa.Column("key_id", sa.String(36), sa.ForeignKey("provider_api_keys.id"), nullable=True),
|
||||
sa.Column("client_api_format", sa.String(50), nullable=False),
|
||||
sa.Column("provider_api_format", sa.String(50), nullable=False),
|
||||
sa.Column("format_converted", sa.Boolean(), server_default=sa.false()),
|
||||
sa.Column("model", sa.String(100), nullable=False),
|
||||
sa.Column("prompt", sa.Text(), nullable=False),
|
||||
sa.Column("original_request_body", sa.JSON(), nullable=True),
|
||||
sa.Column("converted_request_body", sa.JSON(), nullable=True),
|
||||
sa.Column("duration_seconds", sa.Integer(), server_default=sa.text("4")),
|
||||
sa.Column("resolution", sa.String(20), server_default=sa.text("'720p'")),
|
||||
sa.Column("aspect_ratio", sa.String(10), server_default=sa.text("'16:9'")),
|
||||
sa.Column("size", sa.String(20), nullable=True),
|
||||
sa.Column("status", sa.String(20), server_default=sa.text("'pending'")),
|
||||
sa.Column("progress_percent", sa.Integer(), server_default=sa.text("0")),
|
||||
sa.Column("progress_message", sa.String(500), nullable=True),
|
||||
sa.Column("video_url", sa.String(2000), nullable=True),
|
||||
sa.Column("video_urls", sa.JSON(), nullable=True),
|
||||
sa.Column("thumbnail_url", sa.String(2000), nullable=True),
|
||||
sa.Column("video_size_bytes", sa.BigInteger(), nullable=True),
|
||||
sa.Column("video_expires_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("stored_video_path", sa.String(500), nullable=True),
|
||||
sa.Column("storage_provider", sa.String(50), nullable=True),
|
||||
sa.Column("error_code", sa.String(50), nullable=True),
|
||||
sa.Column("error_message", sa.Text(), nullable=True),
|
||||
sa.Column("retry_count", sa.Integer(), server_default=sa.text("0")),
|
||||
sa.Column("max_retries", sa.Integer(), server_default=sa.text("3")),
|
||||
sa.Column("poll_interval_seconds", sa.Integer(), server_default=sa.text("10")),
|
||||
sa.Column("next_poll_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("poll_count", sa.Integer(), server_default=sa.text("0")),
|
||||
sa.Column("max_poll_count", sa.Integer(), server_default=sa.text("360")),
|
||||
sa.Column(
|
||||
"remixed_from_task_id",
|
||||
sa.String(36),
|
||||
sa.ForeignKey("video_tasks.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.text("CURRENT_TIMESTAMP"),
|
||||
),
|
||||
sa.Column("submitted_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("completed_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column(
|
||||
"updated_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.text("CURRENT_TIMESTAMP"),
|
||||
),
|
||||
)
|
||||
|
||||
op.create_index("idx_video_tasks_user_status", "video_tasks", ["user_id", "status"])
|
||||
op.create_index("idx_video_tasks_next_poll", "video_tasks", ["next_poll_at"])
|
||||
op.create_index("idx_video_tasks_external_id", "video_tasks", ["external_task_id"])
|
||||
# 唯一约束:同一用户不能有重复的 external_task_id
|
||||
op.create_unique_constraint(
|
||||
"uq_video_tasks_user_external_id",
|
||||
"video_tasks",
|
||||
["user_id", "external_task_id"],
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
if not table_exists("video_tasks"):
|
||||
return
|
||||
|
||||
op.drop_constraint("uq_video_tasks_user_external_id", "video_tasks", type_="unique")
|
||||
op.drop_index("idx_video_tasks_external_id", table_name="video_tasks")
|
||||
op.drop_index("idx_video_tasks_next_poll", table_name="video_tasks")
|
||||
op.drop_index("idx_video_tasks_user_status", table_name="video_tasks")
|
||||
op.drop_table("video_tasks")
|
||||
@@ -0,0 +1,180 @@
|
||||
"""Add billing system tables and video_tasks.request_metadata
|
||||
|
||||
Revision ID: c8d2e4f6a1b3
|
||||
Revises: b6f1a2c5d8e9
|
||||
Create Date: 2026-01-31 12:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy import inspect
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "c8d2e4f6a1b3"
|
||||
down_revision: Union[str, None] = "b6f1a2c5d8e9"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def table_exists(table_name: str) -> bool:
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
return table_name in inspector.get_table_names()
|
||||
|
||||
|
||||
def column_exists(table_name: str, column_name: str) -> bool:
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
columns = [col["name"] for col in inspector.get_columns(table_name)]
|
||||
return column_name in columns
|
||||
|
||||
|
||||
def index_exists(table_name: str, index_name: str) -> bool:
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
try:
|
||||
indexes = inspector.get_indexes(table_name)
|
||||
except Exception:
|
||||
return False
|
||||
return any(idx.get("name") == index_name for idx in indexes)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ==================== video_tasks.request_metadata ====================
|
||||
if not column_exists("video_tasks", "request_metadata"):
|
||||
op.add_column(
|
||||
"video_tasks",
|
||||
sa.Column("request_metadata", sa.JSON(), nullable=True),
|
||||
)
|
||||
|
||||
# ==================== billing_rules ====================
|
||||
if not table_exists("billing_rules"):
|
||||
op.create_table(
|
||||
"billing_rules",
|
||||
sa.Column("id", sa.String(36), primary_key=True),
|
||||
sa.Column(
|
||||
"global_model_id",
|
||||
sa.String(36),
|
||||
sa.ForeignKey("global_models.id", ondelete="CASCADE"),
|
||||
nullable=True,
|
||||
),
|
||||
sa.Column(
|
||||
"model_id",
|
||||
sa.String(36),
|
||||
sa.ForeignKey("models.id", ondelete="CASCADE"),
|
||||
nullable=True,
|
||||
),
|
||||
sa.Column("name", sa.String(100), nullable=False),
|
||||
sa.Column("task_type", sa.String(20), nullable=False, server_default="chat"),
|
||||
sa.Column("expression", sa.Text(), nullable=False),
|
||||
sa.Column("variables", JSONB, nullable=False, server_default=sa.text("'{}'::jsonb")),
|
||||
sa.Column(
|
||||
"dimension_mappings", JSONB, nullable=False, server_default=sa.text("'{}'::jsonb")
|
||||
),
|
||||
sa.Column("is_enabled", sa.Boolean(), nullable=False, server_default=sa.text("true")),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=sa.text("now()"),
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at",
|
||||
sa.DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=sa.text("now()"),
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"(global_model_id IS NOT NULL AND model_id IS NULL) OR "
|
||||
"(global_model_id IS NULL AND model_id IS NOT NULL)",
|
||||
name="chk_billing_rules_model_ref",
|
||||
),
|
||||
)
|
||||
|
||||
# Partial unique indexes for enabled rules
|
||||
if table_exists("billing_rules"):
|
||||
if not index_exists("billing_rules", "uq_billing_rules_global_model_task"):
|
||||
op.create_index(
|
||||
"uq_billing_rules_global_model_task",
|
||||
"billing_rules",
|
||||
["global_model_id", "task_type"],
|
||||
unique=True,
|
||||
postgresql_where=sa.text("is_enabled = TRUE AND global_model_id IS NOT NULL"),
|
||||
)
|
||||
if not index_exists("billing_rules", "uq_billing_rules_model_task"):
|
||||
op.create_index(
|
||||
"uq_billing_rules_model_task",
|
||||
"billing_rules",
|
||||
["model_id", "task_type"],
|
||||
unique=True,
|
||||
postgresql_where=sa.text("is_enabled = TRUE AND model_id IS NOT NULL"),
|
||||
)
|
||||
|
||||
# ==================== dimension_collectors ====================
|
||||
if not table_exists("dimension_collectors"):
|
||||
op.create_table(
|
||||
"dimension_collectors",
|
||||
sa.Column("id", sa.String(36), primary_key=True),
|
||||
sa.Column("api_format", sa.String(50), nullable=False),
|
||||
sa.Column("task_type", sa.String(20), nullable=False),
|
||||
sa.Column("dimension_name", sa.String(100), nullable=False),
|
||||
sa.Column("source_type", sa.String(20), nullable=False),
|
||||
sa.Column("source_path", sa.String(200), nullable=True),
|
||||
sa.Column("value_type", sa.String(20), nullable=False, server_default="float"),
|
||||
sa.Column("transform_expression", sa.Text(), nullable=True),
|
||||
sa.Column("default_value", sa.String(100), nullable=True),
|
||||
sa.Column("priority", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("is_enabled", sa.Boolean(), nullable=False, server_default=sa.text("true")),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=sa.text("now()"),
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at",
|
||||
sa.DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=sa.text("now()"),
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"(source_type = 'computed' AND source_path IS NULL AND transform_expression IS NOT NULL) OR "
|
||||
"(source_type != 'computed' AND source_path IS NOT NULL)",
|
||||
name="chk_dimension_collectors_source_config",
|
||||
),
|
||||
)
|
||||
|
||||
if table_exists("dimension_collectors"):
|
||||
if not index_exists("dimension_collectors", "uq_dimension_collectors_enabled"):
|
||||
op.create_index(
|
||||
"uq_dimension_collectors_enabled",
|
||||
"dimension_collectors",
|
||||
["api_format", "task_type", "dimension_name", "priority"],
|
||||
unique=True,
|
||||
postgresql_where=sa.text("is_enabled = TRUE"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Drop in reverse order
|
||||
if table_exists("dimension_collectors"):
|
||||
if index_exists("dimension_collectors", "uq_dimension_collectors_enabled"):
|
||||
op.drop_index("uq_dimension_collectors_enabled", table_name="dimension_collectors")
|
||||
op.drop_table("dimension_collectors")
|
||||
|
||||
if table_exists("billing_rules"):
|
||||
if index_exists("billing_rules", "uq_billing_rules_model_task"):
|
||||
op.drop_index("uq_billing_rules_model_task", table_name="billing_rules")
|
||||
if index_exists("billing_rules", "uq_billing_rules_global_model_task"):
|
||||
op.drop_index("uq_billing_rules_global_model_task", table_name="billing_rules")
|
||||
op.drop_table("billing_rules")
|
||||
|
||||
if column_exists("video_tasks", "request_metadata"):
|
||||
op.drop_column("video_tasks", "request_metadata")
|
||||
@@ -0,0 +1,462 @@
|
||||
"""Add api_family/endpoint_kind and migrate api_format to endpoint signature keys
|
||||
|
||||
Revision ID: cf40e6a5c5b1
|
||||
Revises: c8d2e4f6a1b3
|
||||
Create Date: 2026-01-31 15:30:00.000000
|
||||
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from typing import Sequence, Union
|
||||
from uuid import uuid4
|
||||
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy import inspect, text
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "cf40e6a5c5b1"
|
||||
down_revision: Union[str, None] = "c8d2e4f6a1b3"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def _json_loads(val):
|
||||
if val is None:
|
||||
return None
|
||||
if isinstance(val, (dict, list)):
|
||||
return val
|
||||
if isinstance(val, str):
|
||||
try:
|
||||
return json.loads(val)
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def _json_dumps(val):
|
||||
"""将 dict/list 转为 JSON 字符串,None 保持 None"""
|
||||
if val is None:
|
||||
return None
|
||||
if isinstance(val, str):
|
||||
return val
|
||||
return json.dumps(val)
|
||||
|
||||
|
||||
def _normalize_signature(value: str | None) -> str | None:
|
||||
"""
|
||||
Normalize legacy api_format / signature-ish strings to canonical signature key.
|
||||
|
||||
- canonical: `<family>:<kind>` (lowercase)
|
||||
- legacy examples: "OPENAI", "OPENAI_CLI", "GEMINI_VIDEO"
|
||||
"""
|
||||
if value is None:
|
||||
return None
|
||||
raw = str(value).strip()
|
||||
if not raw:
|
||||
return None
|
||||
|
||||
if ":" in raw:
|
||||
fam, kind = raw.split(":", 1)
|
||||
fam = fam.strip().lower()
|
||||
kind = kind.strip().lower()
|
||||
if not fam or not kind:
|
||||
return None
|
||||
return f"{fam}:{kind}"
|
||||
|
||||
upper = raw.upper()
|
||||
if upper.startswith("CLAUDE"):
|
||||
fam = "claude"
|
||||
elif upper.startswith("OPENAI"):
|
||||
fam = "openai"
|
||||
elif upper.startswith("GEMINI"):
|
||||
fam = "gemini"
|
||||
else:
|
||||
return None
|
||||
|
||||
kind = "chat"
|
||||
if upper.endswith("_CLI"):
|
||||
kind = "cli"
|
||||
elif upper.endswith("_VIDEO"):
|
||||
kind = "video"
|
||||
|
||||
return f"{fam}:{kind}"
|
||||
|
||||
|
||||
def _normalize_signature_list(values) -> list[str] | None:
|
||||
if values is None:
|
||||
return None
|
||||
if isinstance(values, str):
|
||||
values = _json_loads(values)
|
||||
if not isinstance(values, list):
|
||||
return None
|
||||
|
||||
out: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for v in values:
|
||||
sig = _normalize_signature(str(v) if v is not None else None)
|
||||
if not sig:
|
||||
continue
|
||||
if sig in seen:
|
||||
continue
|
||||
seen.add(sig)
|
||||
out.append(sig)
|
||||
return out
|
||||
|
||||
|
||||
def _normalize_signature_dict(values) -> dict | None:
|
||||
if values is None:
|
||||
return None
|
||||
if isinstance(values, str):
|
||||
values = _json_loads(values)
|
||||
if not isinstance(values, dict):
|
||||
return None
|
||||
|
||||
out: dict = {}
|
||||
for k, v in values.items():
|
||||
sig = _normalize_signature(str(k) if k is not None else None)
|
||||
if not sig:
|
||||
continue
|
||||
out[sig] = v
|
||||
return out
|
||||
|
||||
|
||||
def _add_video_variants(formats: list[str]) -> list[str]:
|
||||
"""
|
||||
保持原有格式,不自动补齐 video 变体。
|
||||
"""
|
||||
return formats
|
||||
|
||||
|
||||
def table_exists(table_name: str) -> bool:
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
return table_name in inspector.get_table_names()
|
||||
|
||||
|
||||
def column_exists(table_name: str, column_name: str) -> bool:
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
columns = [col["name"] for col in inspector.get_columns(table_name)]
|
||||
return column_name in columns
|
||||
|
||||
|
||||
def index_exists(table_name: str, index_name: str) -> bool:
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
try:
|
||||
indexes = inspector.get_indexes(table_name)
|
||||
except Exception:
|
||||
return False
|
||||
return any(idx.get("name") == index_name for idx in indexes)
|
||||
|
||||
|
||||
def _migrate_format_acceptance_config(cfg) -> dict | None:
|
||||
cfg_obj = _json_loads(cfg)
|
||||
if not isinstance(cfg_obj, dict):
|
||||
return cfg_obj if cfg_obj is None else None
|
||||
|
||||
for key in ("accept_formats", "reject_formats"):
|
||||
raw = cfg_obj.get(key)
|
||||
if not isinstance(raw, list):
|
||||
continue
|
||||
normalized = _normalize_signature_list(raw) or []
|
||||
cfg_obj[key] = normalized
|
||||
|
||||
return cfg_obj
|
||||
|
||||
|
||||
def migrate_provider_endpoints(connection) -> None:
|
||||
"""
|
||||
- 将 provider_endpoints.api_format 统一迁移为 signature key(小写)
|
||||
- 填充/校准 api_family / endpoint_kind
|
||||
- 迁移 format_acceptance_config 中的 accept/reject formats
|
||||
"""
|
||||
rows = connection.execute(text("""
|
||||
SELECT
|
||||
id,
|
||||
api_format,
|
||||
api_family,
|
||||
endpoint_kind,
|
||||
format_acceptance_config
|
||||
FROM provider_endpoints
|
||||
""")).fetchall()
|
||||
|
||||
for row in rows:
|
||||
sig = _normalize_signature(row.api_format)
|
||||
if not sig:
|
||||
continue
|
||||
fam, kind = sig.split(":", 1)
|
||||
|
||||
cfg = _migrate_format_acceptance_config(row.format_acceptance_config)
|
||||
|
||||
connection.execute(
|
||||
text("""
|
||||
UPDATE provider_endpoints
|
||||
SET
|
||||
api_format = :api_format,
|
||||
api_family = :api_family,
|
||||
endpoint_kind = :endpoint_kind,
|
||||
format_acceptance_config = CAST(:format_acceptance_config AS json)
|
||||
WHERE id = :id
|
||||
"""),
|
||||
{
|
||||
"id": row.id,
|
||||
"api_format": sig,
|
||||
"api_family": fam,
|
||||
"endpoint_kind": kind,
|
||||
"format_acceptance_config": _json_dumps(cfg),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def create_video_endpoints(connection) -> None:
|
||||
"""
|
||||
不再自动创建 video endpoint,保持原有配置。
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
def migrate_provider_api_keys(connection) -> None:
|
||||
"""
|
||||
迁移 provider_api_keys:
|
||||
- api_formats -> signature keys(并补齐 video 变体)
|
||||
- dict 字段 key -> signature keys(rate_multipliers/global_priority/health/circuit_breaker)
|
||||
- rate_multipliers/global_priority_by_format 复制 chat -> video(如 openai:chat -> openai:video)
|
||||
"""
|
||||
rows = connection.execute(text("""
|
||||
SELECT
|
||||
id,
|
||||
api_formats,
|
||||
rate_multipliers,
|
||||
global_priority_by_format,
|
||||
health_by_format,
|
||||
circuit_breaker_by_format
|
||||
FROM provider_api_keys
|
||||
""")).fetchall()
|
||||
|
||||
for row in rows:
|
||||
api_formats = _normalize_signature_list(row.api_formats)
|
||||
if api_formats is not None:
|
||||
api_formats = _add_video_variants(api_formats)
|
||||
|
||||
rate_multipliers = _normalize_signature_dict(row.rate_multipliers)
|
||||
global_priority_by_format = _normalize_signature_dict(row.global_priority_by_format)
|
||||
|
||||
health_by_format = _normalize_signature_dict(row.health_by_format)
|
||||
circuit_breaker_by_format = _normalize_signature_dict(row.circuit_breaker_by_format)
|
||||
|
||||
connection.execute(
|
||||
text("""
|
||||
UPDATE provider_api_keys
|
||||
SET
|
||||
api_formats = CAST(:api_formats AS json),
|
||||
rate_multipliers = CAST(:rate_multipliers AS json),
|
||||
global_priority_by_format = CAST(:global_priority_by_format AS json),
|
||||
health_by_format = CAST(:health_by_format AS json),
|
||||
circuit_breaker_by_format = CAST(:circuit_breaker_by_format AS json)
|
||||
WHERE id = :id
|
||||
"""),
|
||||
{
|
||||
"id": row.id,
|
||||
"api_formats": _json_dumps(api_formats),
|
||||
"rate_multipliers": _json_dumps(rate_multipliers),
|
||||
"global_priority_by_format": _json_dumps(global_priority_by_format),
|
||||
"health_by_format": _json_dumps(health_by_format),
|
||||
"circuit_breaker_by_format": _json_dumps(circuit_breaker_by_format),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def migrate_allowed_api_formats(connection, *, table_name: str) -> None:
|
||||
"""迁移 users/api_keys.allowed_api_formats 为 signature keys(并补齐 video 变体)。"""
|
||||
if not table_exists(table_name):
|
||||
return
|
||||
rows = connection.execute(text(f"""
|
||||
SELECT id, allowed_api_formats
|
||||
FROM {table_name}
|
||||
""")).fetchall()
|
||||
|
||||
for row in rows:
|
||||
allowed = _normalize_signature_list(row.allowed_api_formats)
|
||||
if allowed is None:
|
||||
continue
|
||||
allowed = _add_video_variants(allowed)
|
||||
connection.execute(
|
||||
text(f"""
|
||||
UPDATE {table_name}
|
||||
SET allowed_api_formats = CAST(:allowed_api_formats AS json)
|
||||
WHERE id = :id
|
||||
"""),
|
||||
{"id": row.id, "allowed_api_formats": _json_dumps(allowed)},
|
||||
)
|
||||
|
||||
|
||||
def migrate_video_tasks(connection) -> None:
|
||||
"""
|
||||
video_tasks.*_api_format 迁移为 signature keys。
|
||||
|
||||
注意:video_tasks 表天然是 video 任务,因此将 openai/gemini 的 kind 强制归一为 video,
|
||||
以兼容历史上复用 chat 格式存储的旧记录。
|
||||
"""
|
||||
if not table_exists("video_tasks"):
|
||||
return
|
||||
|
||||
rows = connection.execute(text("""
|
||||
SELECT id, client_api_format, provider_api_format
|
||||
FROM video_tasks
|
||||
""")).fetchall()
|
||||
|
||||
for row in rows:
|
||||
client_sig = _normalize_signature(row.client_api_format) or ""
|
||||
provider_sig = _normalize_signature(row.provider_api_format) or ""
|
||||
|
||||
def _force_video(sig: str) -> str:
|
||||
if not sig or ":" not in sig:
|
||||
return sig
|
||||
fam, _kind = sig.split(":", 1)
|
||||
fam = fam.strip().lower()
|
||||
if fam in ("openai", "gemini"):
|
||||
return f"{fam}:video"
|
||||
return sig
|
||||
|
||||
client_sig = _force_video(client_sig)
|
||||
provider_sig = _force_video(provider_sig)
|
||||
|
||||
if not client_sig or not provider_sig:
|
||||
continue
|
||||
|
||||
connection.execute(
|
||||
text("""
|
||||
UPDATE video_tasks
|
||||
SET client_api_format = :client_api_format,
|
||||
provider_api_format = :provider_api_format
|
||||
WHERE id = :id
|
||||
"""),
|
||||
{
|
||||
"id": row.id,
|
||||
"client_api_format": client_sig,
|
||||
"provider_api_format": provider_sig,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def migrate_model_provider_mappings(connection) -> None:
|
||||
"""迁移 models.provider_model_mappings[*].api_formats 为 signature keys。"""
|
||||
if not table_exists("models"):
|
||||
return
|
||||
|
||||
rows = connection.execute(text("""
|
||||
SELECT id, provider_model_mappings
|
||||
FROM models
|
||||
WHERE provider_model_mappings IS NOT NULL
|
||||
""")).fetchall()
|
||||
|
||||
for row in rows:
|
||||
mappings = _json_loads(row.provider_model_mappings)
|
||||
if not isinstance(mappings, list):
|
||||
continue
|
||||
|
||||
changed = False
|
||||
new_mappings: list = []
|
||||
for item in mappings:
|
||||
if not isinstance(item, dict):
|
||||
new_mappings.append(item)
|
||||
continue
|
||||
raw_formats = item.get("api_formats")
|
||||
if isinstance(raw_formats, list):
|
||||
normalized = _normalize_signature_list(raw_formats) or []
|
||||
# 内容比较(而非引用比较),避免已迁移数据被无意义地重复 UPDATE
|
||||
if set(normalized) != set(raw_formats):
|
||||
changed = True
|
||||
item = dict(item)
|
||||
item["api_formats"] = normalized
|
||||
new_mappings.append(item)
|
||||
|
||||
if not changed:
|
||||
continue
|
||||
|
||||
connection.execute(
|
||||
text("""
|
||||
UPDATE models
|
||||
SET provider_model_mappings = CAST(:provider_model_mappings AS json)
|
||||
WHERE id = :id
|
||||
"""),
|
||||
{"id": row.id, "provider_model_mappings": _json_dumps(new_mappings)},
|
||||
)
|
||||
|
||||
|
||||
def migrate_dimension_collectors(connection) -> None:
|
||||
"""迁移 dimension_collectors.api_format 为 signature keys(如果存在历史数据)。"""
|
||||
if not table_exists("dimension_collectors"):
|
||||
return
|
||||
|
||||
rows = connection.execute(text("""
|
||||
SELECT id, api_format
|
||||
FROM dimension_collectors
|
||||
WHERE api_format IS NOT NULL
|
||||
""")).fetchall()
|
||||
|
||||
for row in rows:
|
||||
sig = _normalize_signature(row.api_format)
|
||||
if not sig:
|
||||
continue
|
||||
connection.execute(
|
||||
text("""
|
||||
UPDATE dimension_collectors
|
||||
SET api_format = :api_format
|
||||
WHERE id = :id
|
||||
"""),
|
||||
{"id": row.id, "api_format": sig},
|
||||
)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
if not table_exists("provider_endpoints"):
|
||||
return
|
||||
|
||||
# ==================== provider_endpoints.api_family / endpoint_kind ====================
|
||||
if not column_exists("provider_endpoints", "api_family"):
|
||||
op.add_column("provider_endpoints", sa.Column("api_family", sa.String(50), nullable=True))
|
||||
if not column_exists("provider_endpoints", "endpoint_kind"):
|
||||
op.add_column(
|
||||
"provider_endpoints", sa.Column("endpoint_kind", sa.String(50), nullable=True)
|
||||
)
|
||||
|
||||
# ==================== idx_provider_family_kind ====================
|
||||
if not index_exists("provider_endpoints", "idx_provider_family_kind"):
|
||||
op.create_index(
|
||||
"idx_provider_family_kind",
|
||||
"provider_endpoints",
|
||||
["provider_id", "api_family", "endpoint_kind"],
|
||||
)
|
||||
|
||||
# ==================== data migrations (idempotent) ====================
|
||||
conn = op.get_bind()
|
||||
|
||||
migrate_provider_endpoints(conn)
|
||||
create_video_endpoints(conn)
|
||||
|
||||
if table_exists("provider_api_keys"):
|
||||
migrate_provider_api_keys(conn)
|
||||
|
||||
migrate_allowed_api_formats(conn, table_name="users")
|
||||
migrate_allowed_api_formats(conn, table_name="api_keys")
|
||||
migrate_video_tasks(conn)
|
||||
migrate_model_provider_mappings(conn)
|
||||
migrate_dimension_collectors(conn)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Drop index/columns only; data changes are intentionally kept (safe rollback strategy).
|
||||
if table_exists("provider_endpoints"):
|
||||
if index_exists("provider_endpoints", "idx_provider_family_kind"):
|
||||
op.drop_index("idx_provider_family_kind", table_name="provider_endpoints")
|
||||
if column_exists("provider_endpoints", "endpoint_kind"):
|
||||
op.drop_column("provider_endpoints", "endpoint_kind")
|
||||
if column_exists("provider_endpoints", "api_family"):
|
||||
op.drop_column("provider_endpoints", "api_family")
|
||||
@@ -0,0 +1,329 @@
|
||||
"""Add usage billing, video_tasks fields, gemini_file_mappings, provider format conversion, and indexes
|
||||
|
||||
Revision ID: a2f1b3c4d5e6
|
||||
Revises: cf40e6a5c5b1
|
||||
Create Date: 2026-02-01 12:00:00+00:00
|
||||
|
||||
Changes:
|
||||
1. usage 表:
|
||||
- 添加 billing_status (pending/settled/void),用于表示结算状态
|
||||
- 添加 finalized_at,用于记录结算完成时间
|
||||
- 添加 (provider_name, created_at) 和 (model, created_at) 索引
|
||||
|
||||
2. video_tasks 表:
|
||||
- 添加 request_id(全局唯一),用于与 Usage/RequestCandidate 建立稳定关联
|
||||
- 添加 short_id (Gemini-style short ID)
|
||||
|
||||
3. gemini_file_mappings 表:
|
||||
- 创建新表用于文件映射
|
||||
- 添加 source_hash 字段用于关联相同源文件
|
||||
|
||||
4. providers 表:
|
||||
- 添加 enable_format_conversion 开关字段
|
||||
|
||||
5. request_candidates 表:
|
||||
- 添加 created_at 索引
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import secrets
|
||||
import string
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy import inspect, text
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "a2f1b3c4d5e6"
|
||||
down_revision: Union[str, None] = "cf40e6a5c5b1"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def table_exists(table_name: str) -> bool:
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
inspector.clear_cache()
|
||||
return table_name in inspector.get_table_names()
|
||||
|
||||
|
||||
def column_exists(table_name: str, column_name: str) -> bool:
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
# Clear cached schema info to get fresh data
|
||||
inspector.clear_cache()
|
||||
columns = [col["name"] for col in inspector.get_columns(table_name)]
|
||||
return column_name in columns
|
||||
|
||||
|
||||
def index_exists(table_name: str, index_name: str) -> bool:
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
inspector.clear_cache()
|
||||
indexes = inspector.get_indexes(table_name)
|
||||
return any(idx.get("name") == index_name for idx in indexes)
|
||||
|
||||
|
||||
def unique_constraint_exists(table_name: str, constraint_name: str) -> bool:
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
inspector.clear_cache()
|
||||
constraints = inspector.get_unique_constraints(table_name)
|
||||
return any(c.get("name") == constraint_name for c in constraints)
|
||||
|
||||
|
||||
def generate_short_id(length: int = 12) -> str:
|
||||
"""Generate a Gemini-style short ID (lowercase letters + digits)"""
|
||||
alphabet = string.ascii_lowercase + string.digits
|
||||
return "".join(secrets.choice(alphabet) for _ in range(length))
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
dialect = bind.dialect.name
|
||||
|
||||
# =========================================================================
|
||||
# 1. usage 表: billing_status + finalized_at + 索引
|
||||
# =========================================================================
|
||||
if table_exists("usage"):
|
||||
if not column_exists("usage", "billing_status"):
|
||||
op.add_column(
|
||||
"usage",
|
||||
sa.Column(
|
||||
"billing_status",
|
||||
sa.String(20),
|
||||
nullable=False,
|
||||
server_default="settled",
|
||||
),
|
||||
)
|
||||
|
||||
if not column_exists("usage", "finalized_at"):
|
||||
op.add_column(
|
||||
"usage",
|
||||
sa.Column("finalized_at", sa.DateTime(timezone=True), nullable=True),
|
||||
)
|
||||
|
||||
if not index_exists("usage", "idx_usage_billing_status"):
|
||||
op.create_index("idx_usage_billing_status", "usage", ["billing_status"])
|
||||
|
||||
# (provider_name, created_at) — provider list / dashboard queries
|
||||
if (
|
||||
column_exists("usage", "provider_name")
|
||||
and column_exists("usage", "created_at")
|
||||
and not index_exists("usage", "idx_usage_provider_created")
|
||||
):
|
||||
op.create_index("idx_usage_provider_created", "usage", ["provider_name", "created_at"])
|
||||
|
||||
# (model, created_at) — model analytics / recent requests queries
|
||||
if (
|
||||
column_exists("usage", "model")
|
||||
and column_exists("usage", "created_at")
|
||||
and not index_exists("usage", "idx_usage_model_created")
|
||||
):
|
||||
op.create_index("idx_usage_model_created", "usage", ["model", "created_at"])
|
||||
|
||||
# =========================================================================
|
||||
# 2. video_tasks 表: request_id + short_id
|
||||
# =========================================================================
|
||||
if table_exists("video_tasks"):
|
||||
# --- request_id ---
|
||||
if not column_exists("video_tasks", "request_id"):
|
||||
op.add_column(
|
||||
"video_tasks",
|
||||
sa.Column("request_id", sa.String(100), nullable=True),
|
||||
)
|
||||
|
||||
# 回填 request_id
|
||||
if dialect == "postgresql":
|
||||
op.execute("""
|
||||
UPDATE video_tasks
|
||||
SET request_id = COALESCE(request_metadata->>'request_id', id)
|
||||
WHERE request_id IS NULL
|
||||
""")
|
||||
elif dialect == "sqlite":
|
||||
op.execute("""
|
||||
UPDATE video_tasks
|
||||
SET request_id = COALESCE(json_extract(request_metadata, '$.request_id'), id)
|
||||
WHERE request_id IS NULL
|
||||
""")
|
||||
else:
|
||||
op.execute("""
|
||||
UPDATE video_tasks
|
||||
SET request_id = id
|
||||
WHERE request_id IS NULL
|
||||
""")
|
||||
|
||||
if dialect == "postgresql":
|
||||
op.alter_column("video_tasks", "request_id", nullable=False)
|
||||
|
||||
if not index_exists("video_tasks", "idx_video_tasks_request_id"):
|
||||
op.create_index("idx_video_tasks_request_id", "video_tasks", ["request_id"])
|
||||
|
||||
if not unique_constraint_exists("video_tasks", "uq_video_tasks_request_id"):
|
||||
op.create_unique_constraint(
|
||||
"uq_video_tasks_request_id",
|
||||
"video_tasks",
|
||||
["request_id"],
|
||||
)
|
||||
|
||||
# --- short_id ---
|
||||
if not column_exists("video_tasks", "short_id"):
|
||||
op.add_column(
|
||||
"video_tasks",
|
||||
sa.Column("short_id", sa.String(16), nullable=True),
|
||||
)
|
||||
|
||||
# Populate existing rows with unique short_ids
|
||||
result = bind.execute(text("SELECT id FROM video_tasks WHERE short_id IS NULL"))
|
||||
for row in result:
|
||||
short_id = generate_short_id()
|
||||
bind.execute(
|
||||
text("UPDATE video_tasks SET short_id = :short_id WHERE id = :id"),
|
||||
{"short_id": short_id, "id": row[0]},
|
||||
)
|
||||
|
||||
op.alter_column("video_tasks", "short_id", nullable=False)
|
||||
op.create_index("ix_video_tasks_short_id", "video_tasks", ["short_id"], unique=True)
|
||||
|
||||
# =========================================================================
|
||||
# 3. gemini_file_mappings 表
|
||||
# =========================================================================
|
||||
if not table_exists("gemini_file_mappings"):
|
||||
op.create_table(
|
||||
"gemini_file_mappings",
|
||||
sa.Column("id", sa.String(36), primary_key=True),
|
||||
sa.Column("file_name", sa.String(255), nullable=False, unique=True),
|
||||
sa.Column(
|
||||
"key_id",
|
||||
sa.String(36),
|
||||
sa.ForeignKey("provider_api_keys.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"user_id",
|
||||
sa.String(36),
|
||||
sa.ForeignKey("users.id", ondelete="CASCADE"),
|
||||
nullable=True,
|
||||
),
|
||||
sa.Column("display_name", sa.String(255), nullable=True),
|
||||
sa.Column("mime_type", sa.String(100), nullable=True),
|
||||
sa.Column("source_hash", sa.String(64), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
|
||||
)
|
||||
|
||||
op.create_index("ix_gemini_file_mappings_id", "gemini_file_mappings", ["id"])
|
||||
op.create_index(
|
||||
"ix_gemini_file_mappings_file_name", "gemini_file_mappings", ["file_name"], unique=True
|
||||
)
|
||||
op.create_index("ix_gemini_file_mappings_key_id", "gemini_file_mappings", ["key_id"])
|
||||
op.create_index("ix_gemini_file_mappings_user_id", "gemini_file_mappings", ["user_id"])
|
||||
op.create_index("idx_gemini_file_mappings_expires", "gemini_file_mappings", ["expires_at"])
|
||||
op.create_index(
|
||||
"idx_gemini_file_mappings_source_hash", "gemini_file_mappings", ["source_hash"]
|
||||
)
|
||||
else:
|
||||
# 表已存在,只添加 source_hash
|
||||
if not column_exists("gemini_file_mappings", "source_hash"):
|
||||
op.add_column(
|
||||
"gemini_file_mappings",
|
||||
sa.Column("source_hash", sa.String(64), nullable=True),
|
||||
)
|
||||
op.create_index(
|
||||
"idx_gemini_file_mappings_source_hash",
|
||||
"gemini_file_mappings",
|
||||
["source_hash"],
|
||||
)
|
||||
|
||||
# =========================================================================
|
||||
# 4. providers 表: enable_format_conversion
|
||||
# =========================================================================
|
||||
if table_exists("providers") and not column_exists("providers", "enable_format_conversion"):
|
||||
op.add_column(
|
||||
"providers",
|
||||
sa.Column(
|
||||
"enable_format_conversion",
|
||||
sa.Boolean(),
|
||||
nullable=False,
|
||||
server_default=sa.text("false"),
|
||||
),
|
||||
)
|
||||
|
||||
# =========================================================================
|
||||
# 5. request_candidates 表: created_at 索引
|
||||
# =========================================================================
|
||||
if table_exists("request_candidates"):
|
||||
if not index_exists("request_candidates", "idx_request_candidates_created_at"):
|
||||
op.create_index(
|
||||
"idx_request_candidates_created_at",
|
||||
"request_candidates",
|
||||
["created_at"],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
dialect = bind.dialect.name
|
||||
|
||||
# =========================================================================
|
||||
# 5. request_candidates 表回滚
|
||||
# =========================================================================
|
||||
if table_exists("request_candidates"):
|
||||
if index_exists("request_candidates", "idx_request_candidates_created_at"):
|
||||
op.drop_index("idx_request_candidates_created_at", table_name="request_candidates")
|
||||
|
||||
# =========================================================================
|
||||
# 4. providers 表回滚
|
||||
# =========================================================================
|
||||
if table_exists("providers") and column_exists("providers", "enable_format_conversion"):
|
||||
op.drop_column("providers", "enable_format_conversion")
|
||||
|
||||
# =========================================================================
|
||||
# 3. gemini_file_mappings 表回滚
|
||||
# =========================================================================
|
||||
if table_exists("gemini_file_mappings"):
|
||||
op.drop_index("idx_gemini_file_mappings_source_hash", table_name="gemini_file_mappings")
|
||||
op.drop_index("idx_gemini_file_mappings_expires", table_name="gemini_file_mappings")
|
||||
op.drop_index("ix_gemini_file_mappings_user_id", table_name="gemini_file_mappings")
|
||||
op.drop_index("ix_gemini_file_mappings_key_id", table_name="gemini_file_mappings")
|
||||
op.drop_index("ix_gemini_file_mappings_file_name", table_name="gemini_file_mappings")
|
||||
op.drop_index("ix_gemini_file_mappings_id", table_name="gemini_file_mappings")
|
||||
op.drop_table("gemini_file_mappings")
|
||||
|
||||
# =========================================================================
|
||||
# 2. video_tasks 表回滚
|
||||
# =========================================================================
|
||||
if table_exists("video_tasks"):
|
||||
# short_id
|
||||
if column_exists("video_tasks", "short_id"):
|
||||
if index_exists("video_tasks", "ix_video_tasks_short_id"):
|
||||
op.drop_index("ix_video_tasks_short_id", table_name="video_tasks")
|
||||
op.drop_column("video_tasks", "short_id")
|
||||
|
||||
# request_id
|
||||
if column_exists("video_tasks", "request_id"):
|
||||
if dialect == "postgresql":
|
||||
if unique_constraint_exists("video_tasks", "uq_video_tasks_request_id"):
|
||||
op.drop_constraint("uq_video_tasks_request_id", "video_tasks", type_="unique")
|
||||
if index_exists("video_tasks", "idx_video_tasks_request_id"):
|
||||
op.drop_index("idx_video_tasks_request_id", table_name="video_tasks")
|
||||
op.drop_column("video_tasks", "request_id")
|
||||
|
||||
# =========================================================================
|
||||
# 1. usage 表回滚
|
||||
# =========================================================================
|
||||
if table_exists("usage"):
|
||||
if index_exists("usage", "idx_usage_model_created"):
|
||||
op.drop_index("idx_usage_model_created", table_name="usage")
|
||||
if index_exists("usage", "idx_usage_provider_created"):
|
||||
op.drop_index("idx_usage_provider_created", table_name="usage")
|
||||
if index_exists("usage", "idx_usage_billing_status"):
|
||||
op.drop_index("idx_usage_billing_status", table_name="usage")
|
||||
if column_exists("usage", "finalized_at"):
|
||||
op.drop_column("usage", "finalized_at")
|
||||
if column_exists("usage", "billing_status"):
|
||||
op.drop_column("usage", "billing_status")
|
||||
@@ -0,0 +1,60 @@
|
||||
"""Add video_duration_seconds to video_tasks and body_rules to provider_endpoints
|
||||
|
||||
Revision ID: b3c4d5e6f7a8
|
||||
Revises: a2f1b3c4d5e6
|
||||
Create Date: 2026-02-03 15:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy import inspect
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "b3c4d5e6f7a8"
|
||||
down_revision: Union[str, None] = "a2f1b3c4d5e6"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def _column_exists(table_name: str, column_name: str) -> bool:
|
||||
"""Check if a column exists in a 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 upgrade() -> None:
|
||||
# 1. Add video_duration_seconds to video_tasks
|
||||
if not _column_exists("video_tasks", "video_duration_seconds"):
|
||||
op.add_column(
|
||||
"video_tasks",
|
||||
sa.Column("video_duration_seconds", sa.Float(), nullable=True),
|
||||
)
|
||||
|
||||
# 2. Add body_rules to provider_endpoints
|
||||
# 请求体规则支持三种操作:
|
||||
# - set: 设置/覆盖字段 {"action": "set", "path": "metadata", "value": {"custom": "val"}}
|
||||
# - drop: 删除字段 {"action": "drop", "path": "unwanted_field"}
|
||||
# - rename: 重命名字段 {"action": "rename", "from": "old_key", "to": "new_key"}
|
||||
if not _column_exists("provider_endpoints", "body_rules"):
|
||||
op.add_column(
|
||||
"provider_endpoints",
|
||||
sa.Column("body_rules", sa.JSON(), nullable=True),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Remove body_rules from provider_endpoints
|
||||
if _column_exists("provider_endpoints", "body_rules"):
|
||||
op.drop_column("provider_endpoints", "body_rules")
|
||||
|
||||
# Remove video_duration_seconds from video_tasks
|
||||
if _column_exists("video_tasks", "video_duration_seconds"):
|
||||
op.drop_column("video_tasks", "video_duration_seconds")
|
||||
@@ -0,0 +1,347 @@
|
||||
"""add_stats_hourly_and_daily_complete_flag
|
||||
|
||||
Revision ID: c4e8f9a1b2c3
|
||||
Revises: b3c4d5e6f7a8
|
||||
Create Date: 2026-02-04 12:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy import inspect
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "c4e8f9a1b2c3"
|
||||
down_revision: Union[str, None] = "b3c4d5e6f7a8"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def _table_exists(table_name: str) -> bool:
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
return table_name in inspector.get_table_names()
|
||||
|
||||
|
||||
def _index_exists(table_name: str, index_name: str) -> bool:
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
indexes = [idx["name"] for idx in inspector.get_indexes(table_name)]
|
||||
return index_name in indexes
|
||||
|
||||
|
||||
def _column_exists(table_name: str, column_name: str) -> bool:
|
||||
bind = op.get_bind()
|
||||
# Use information_schema for more reliable detection (inspector can have caching issues)
|
||||
result = bind.execute(
|
||||
sa.text(
|
||||
"SELECT EXISTS ("
|
||||
"SELECT 1 FROM information_schema.columns "
|
||||
"WHERE table_name = :table AND column_name = :column"
|
||||
")"
|
||||
),
|
||||
{"table": table_name, "column": column_name},
|
||||
)
|
||||
return bool(result.scalar())
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
if _table_exists("stats_daily"):
|
||||
if not _column_exists("stats_daily", "is_complete"):
|
||||
op.add_column(
|
||||
"stats_daily",
|
||||
sa.Column("is_complete", sa.Boolean(), nullable=False, server_default=sa.false()),
|
||||
)
|
||||
op.execute("UPDATE stats_daily SET is_complete = true")
|
||||
if not _column_exists("stats_daily", "aggregated_at"):
|
||||
op.add_column(
|
||||
"stats_daily",
|
||||
sa.Column("aggregated_at", sa.DateTime(timezone=True), nullable=True),
|
||||
)
|
||||
|
||||
if not _table_exists("stats_hourly"):
|
||||
op.create_table(
|
||||
"stats_hourly",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("hour_utc", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("total_requests", sa.Integer(), nullable=False),
|
||||
sa.Column("success_requests", sa.Integer(), nullable=False),
|
||||
sa.Column("error_requests", sa.Integer(), nullable=False),
|
||||
sa.Column("input_tokens", sa.BigInteger(), nullable=False),
|
||||
sa.Column("output_tokens", sa.BigInteger(), nullable=False),
|
||||
sa.Column("cache_creation_tokens", sa.BigInteger(), nullable=False),
|
||||
sa.Column("cache_read_tokens", sa.BigInteger(), nullable=False),
|
||||
sa.Column("total_cost", sa.Float(), nullable=False),
|
||||
sa.Column("actual_total_cost", sa.Float(), nullable=False),
|
||||
sa.Column("avg_response_time_ms", sa.Float(), nullable=False),
|
||||
sa.Column("is_complete", sa.Boolean(), nullable=False),
|
||||
sa.Column("aggregated_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("hour_utc", name="uq_stats_hourly_hour"),
|
||||
)
|
||||
op.create_index("idx_stats_hourly_hour", "stats_hourly", ["hour_utc"], unique=False)
|
||||
|
||||
if not _table_exists("stats_hourly_user"):
|
||||
op.create_table(
|
||||
"stats_hourly_user",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("hour_utc", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("user_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("total_requests", sa.Integer(), nullable=False),
|
||||
sa.Column("success_requests", sa.Integer(), nullable=False),
|
||||
sa.Column("error_requests", sa.Integer(), nullable=False),
|
||||
sa.Column("input_tokens", sa.BigInteger(), nullable=False),
|
||||
sa.Column("output_tokens", sa.BigInteger(), nullable=False),
|
||||
sa.Column("total_cost", sa.Float(), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("hour_utc", "user_id", name="uq_stats_hourly_user"),
|
||||
)
|
||||
op.create_index(
|
||||
"idx_stats_hourly_user_hour", "stats_hourly_user", ["hour_utc"], unique=False
|
||||
)
|
||||
op.create_index(
|
||||
"idx_stats_hourly_user_user_hour",
|
||||
"stats_hourly_user",
|
||||
["user_id", "hour_utc"],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
if not _table_exists("stats_hourly_model"):
|
||||
op.create_table(
|
||||
"stats_hourly_model",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("hour_utc", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("model", sa.String(length=100), nullable=False),
|
||||
sa.Column("total_requests", sa.Integer(), nullable=False),
|
||||
sa.Column("input_tokens", sa.BigInteger(), nullable=False),
|
||||
sa.Column("output_tokens", sa.BigInteger(), nullable=False),
|
||||
sa.Column("total_cost", sa.Float(), nullable=False),
|
||||
sa.Column("avg_response_time_ms", sa.Float(), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("hour_utc", "model", name="uq_stats_hourly_model"),
|
||||
)
|
||||
op.create_index(
|
||||
"idx_stats_hourly_model_hour", "stats_hourly_model", ["hour_utc"], unique=False
|
||||
)
|
||||
op.create_index(
|
||||
"idx_stats_hourly_model_model_hour",
|
||||
"stats_hourly_model",
|
||||
["model", "hour_utc"],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
if not _table_exists("stats_hourly_provider"):
|
||||
op.create_table(
|
||||
"stats_hourly_provider",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("hour_utc", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("provider_name", sa.String(length=100), nullable=False),
|
||||
sa.Column("total_requests", sa.Integer(), nullable=False),
|
||||
sa.Column("input_tokens", sa.BigInteger(), nullable=False),
|
||||
sa.Column("output_tokens", sa.BigInteger(), nullable=False),
|
||||
sa.Column("total_cost", sa.Float(), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("hour_utc", "provider_name", name="uq_stats_hourly_provider"),
|
||||
)
|
||||
op.create_index(
|
||||
"idx_stats_hourly_provider_hour",
|
||||
"stats_hourly_provider",
|
||||
["hour_utc"],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
if not _table_exists("stats_daily_api_key"):
|
||||
op.create_table(
|
||||
"stats_daily_api_key",
|
||||
sa.Column("id", sa.String(length=36), primary_key=True),
|
||||
sa.Column(
|
||||
"api_key_id",
|
||||
sa.String(length=36),
|
||||
sa.ForeignKey("api_keys.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("date", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("total_requests", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("success_requests", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("error_requests", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("input_tokens", sa.BigInteger(), nullable=False, server_default="0"),
|
||||
sa.Column("output_tokens", sa.BigInteger(), nullable=False, server_default="0"),
|
||||
sa.Column("cache_creation_tokens", sa.BigInteger(), nullable=False, server_default="0"),
|
||||
sa.Column("cache_read_tokens", sa.BigInteger(), nullable=False, server_default="0"),
|
||||
sa.Column("total_cost", sa.Float(), nullable=False, server_default="0"),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=sa.text("CURRENT_TIMESTAMP"),
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at",
|
||||
sa.DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=sa.text("CURRENT_TIMESTAMP"),
|
||||
),
|
||||
sa.UniqueConstraint("api_key_id", "date", name="uq_stats_daily_api_key"),
|
||||
)
|
||||
|
||||
if _table_exists("stats_daily_api_key"):
|
||||
if not _index_exists("stats_daily_api_key", "idx_stats_daily_api_key_date"):
|
||||
op.create_index("idx_stats_daily_api_key_date", "stats_daily_api_key", ["date"])
|
||||
if not _index_exists("stats_daily_api_key", "idx_stats_daily_api_key_key_date"):
|
||||
op.create_index(
|
||||
"idx_stats_daily_api_key_key_date",
|
||||
"stats_daily_api_key",
|
||||
["api_key_id", "date"],
|
||||
)
|
||||
if not _index_exists("stats_daily_api_key", "idx_stats_daily_api_key_date_requests"):
|
||||
op.create_index(
|
||||
"idx_stats_daily_api_key_date_requests",
|
||||
"stats_daily_api_key",
|
||||
["date", "total_requests"],
|
||||
)
|
||||
if not _index_exists("stats_daily_api_key", "idx_stats_daily_api_key_date_cost"):
|
||||
op.create_index(
|
||||
"idx_stats_daily_api_key_date_cost",
|
||||
"stats_daily_api_key",
|
||||
["date", "total_cost"],
|
||||
)
|
||||
|
||||
if _table_exists("usage"):
|
||||
if not _column_exists("usage", "error_category"):
|
||||
op.add_column(
|
||||
"usage",
|
||||
sa.Column("error_category", sa.String(length=50), nullable=True),
|
||||
)
|
||||
op.create_index("idx_usage_error_category", "usage", ["error_category"], unique=False)
|
||||
|
||||
if _table_exists("stats_daily"):
|
||||
for name in (
|
||||
"p50_response_time_ms",
|
||||
"p90_response_time_ms",
|
||||
"p99_response_time_ms",
|
||||
"p50_first_byte_time_ms",
|
||||
"p90_first_byte_time_ms",
|
||||
"p99_first_byte_time_ms",
|
||||
):
|
||||
if not _column_exists("stats_daily", name):
|
||||
op.add_column("stats_daily", sa.Column(name, sa.Integer(), nullable=True))
|
||||
|
||||
if not _table_exists("stats_daily_error"):
|
||||
op.create_table(
|
||||
"stats_daily_error",
|
||||
sa.Column("id", sa.String(length=36), primary_key=True),
|
||||
sa.Column("date", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("error_category", sa.String(length=50), nullable=False),
|
||||
sa.Column("provider_name", sa.String(length=100), nullable=True),
|
||||
sa.Column("model", sa.String(length=100), nullable=True),
|
||||
sa.Column("count", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=sa.text("CURRENT_TIMESTAMP"),
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at",
|
||||
sa.DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=sa.text("CURRENT_TIMESTAMP"),
|
||||
),
|
||||
sa.UniqueConstraint(
|
||||
"date",
|
||||
"error_category",
|
||||
"provider_name",
|
||||
"model",
|
||||
name="uq_stats_daily_error",
|
||||
),
|
||||
)
|
||||
|
||||
if _table_exists("stats_daily_error"):
|
||||
if not _index_exists("stats_daily_error", "idx_stats_daily_error_date"):
|
||||
op.create_index("idx_stats_daily_error_date", "stats_daily_error", ["date"])
|
||||
if not _index_exists("stats_daily_error", "idx_stats_daily_error_category"):
|
||||
op.create_index(
|
||||
"idx_stats_daily_error_category",
|
||||
"stats_daily_error",
|
||||
["date", "error_category"],
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
if _table_exists("stats_daily_error"):
|
||||
if _index_exists("stats_daily_error", "idx_stats_daily_error_category"):
|
||||
op.drop_index("idx_stats_daily_error_category", table_name="stats_daily_error")
|
||||
if _index_exists("stats_daily_error", "idx_stats_daily_error_date"):
|
||||
op.drop_index("idx_stats_daily_error_date", table_name="stats_daily_error")
|
||||
op.drop_table("stats_daily_error")
|
||||
|
||||
if _table_exists("stats_daily"):
|
||||
for name in (
|
||||
"p50_response_time_ms",
|
||||
"p90_response_time_ms",
|
||||
"p99_response_time_ms",
|
||||
"p50_first_byte_time_ms",
|
||||
"p90_first_byte_time_ms",
|
||||
"p99_first_byte_time_ms",
|
||||
):
|
||||
if _column_exists("stats_daily", name):
|
||||
op.drop_column("stats_daily", name)
|
||||
|
||||
if _table_exists("usage") and _column_exists("usage", "error_category"):
|
||||
if _index_exists("usage", "idx_usage_error_category"):
|
||||
op.drop_index("idx_usage_error_category", table_name="usage")
|
||||
op.drop_column("usage", "error_category")
|
||||
|
||||
if _table_exists("stats_daily_api_key"):
|
||||
if _index_exists("stats_daily_api_key", "idx_stats_daily_api_key_date_cost"):
|
||||
op.drop_index("idx_stats_daily_api_key_date_cost", table_name="stats_daily_api_key")
|
||||
if _index_exists("stats_daily_api_key", "idx_stats_daily_api_key_date_requests"):
|
||||
op.drop_index("idx_stats_daily_api_key_date_requests", table_name="stats_daily_api_key")
|
||||
if _index_exists("stats_daily_api_key", "idx_stats_daily_api_key_key_date"):
|
||||
op.drop_index("idx_stats_daily_api_key_key_date", table_name="stats_daily_api_key")
|
||||
if _index_exists("stats_daily_api_key", "idx_stats_daily_api_key_date"):
|
||||
op.drop_index("idx_stats_daily_api_key_date", table_name="stats_daily_api_key")
|
||||
op.drop_table("stats_daily_api_key")
|
||||
|
||||
if _table_exists("stats_hourly_provider"):
|
||||
if _index_exists("stats_hourly_provider", "idx_stats_hourly_provider_hour"):
|
||||
op.drop_index("idx_stats_hourly_provider_hour", table_name="stats_hourly_provider")
|
||||
op.drop_table("stats_hourly_provider")
|
||||
|
||||
if _table_exists("stats_hourly_model"):
|
||||
if _index_exists("stats_hourly_model", "idx_stats_hourly_model_model_hour"):
|
||||
op.drop_index("idx_stats_hourly_model_model_hour", table_name="stats_hourly_model")
|
||||
if _index_exists("stats_hourly_model", "idx_stats_hourly_model_hour"):
|
||||
op.drop_index("idx_stats_hourly_model_hour", table_name="stats_hourly_model")
|
||||
op.drop_table("stats_hourly_model")
|
||||
|
||||
if _table_exists("stats_hourly_user"):
|
||||
if _index_exists("stats_hourly_user", "idx_stats_hourly_user_user_hour"):
|
||||
op.drop_index("idx_stats_hourly_user_user_hour", table_name="stats_hourly_user")
|
||||
if _index_exists("stats_hourly_user", "idx_stats_hourly_user_hour"):
|
||||
op.drop_index("idx_stats_hourly_user_hour", table_name="stats_hourly_user")
|
||||
op.drop_table("stats_hourly_user")
|
||||
|
||||
if _table_exists("stats_hourly"):
|
||||
if _index_exists("stats_hourly", "idx_stats_hourly_hour"):
|
||||
op.drop_index("idx_stats_hourly_hour", table_name="stats_hourly")
|
||||
op.drop_table("stats_hourly")
|
||||
|
||||
if _table_exists("stats_daily"):
|
||||
if _column_exists("stats_daily", "aggregated_at"):
|
||||
op.drop_column("stats_daily", "aggregated_at")
|
||||
if _column_exists("stats_daily", "is_complete"):
|
||||
op.drop_column("stats_daily", "is_complete")
|
||||
@@ -0,0 +1,205 @@
|
||||
"""Add provider_type, upstream_metadata, oauth_invalid fields and expand string columns to TEXT
|
||||
|
||||
- Add providers.provider_type (String(20), server_default="custom")
|
||||
- Add provider_api_keys.upstream_metadata (JSON, nullable)
|
||||
- Add provider_api_keys.oauth_invalid_at (DateTime, nullable) - OAuth Token 失效时间
|
||||
- Add provider_api_keys.oauth_invalid_reason (String(255), nullable) - OAuth Token 失效原因
|
||||
- Expand multiple VARCHAR columns to TEXT for long values (OAuth tokens, LDAP DN, URLs, etc.)
|
||||
|
||||
Revision ID: b5c6d7e8f9a0
|
||||
Revises: c4e8f9a1b2c3
|
||||
Create Date: 2026-02-04 15:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy import inspect
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "b5c6d7e8f9a0"
|
||||
down_revision: Union[str, None] = "c4e8f9a1b2c3"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
# 需要扩展为 TEXT 的列(表名, 列名, 原始类型长度)
|
||||
COLUMNS_TO_EXPAND = [
|
||||
("provider_api_keys", "api_key", 500), # OAuth tokens can be very long
|
||||
(
|
||||
"provider_api_keys",
|
||||
"auth_config",
|
||||
None,
|
||||
), # 确保 auth_config 是 TEXT 类型(可能从 JSON 迁移过来)
|
||||
("ldap_configs", "bind_dn", 255), # LDAP DN can be deeply nested
|
||||
("ldap_configs", "base_dn", 255), # LDAP DN can be deeply nested
|
||||
("ldap_configs", "user_search_filter", 500), # Complex LDAP filters
|
||||
("oauth_providers", "client_id", 255), # Some OAuth providers use JWT client_id
|
||||
]
|
||||
|
||||
|
||||
def column_exists(table_name: str, column_name: str) -> bool:
|
||||
"""检查列是否已存在"""
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
columns = [col["name"] for col in inspector.get_columns(table_name)]
|
||||
return column_name in columns
|
||||
|
||||
|
||||
def table_exists(table_name: str) -> bool:
|
||||
"""检查表是否存在"""
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
return table_name in inspector.get_table_names()
|
||||
|
||||
|
||||
def is_sqlite() -> bool:
|
||||
"""检查是否为 SQLite 数据库"""
|
||||
bind = op.get_bind()
|
||||
return bind.dialect.name == "sqlite"
|
||||
|
||||
|
||||
def get_column_type(table_name: str, column_name: str) -> str | None:
|
||||
"""获取列的数据类型"""
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
for col in inspector.get_columns(table_name):
|
||||
if col["name"] == column_name:
|
||||
return str(col["type"]).upper()
|
||||
return None
|
||||
|
||||
|
||||
def expand_column_to_text(table_name: str, column_name: str, original_length: int | None) -> None:
|
||||
"""将 VARCHAR 列扩展为 TEXT(兼容 SQLite)"""
|
||||
if not table_exists(table_name):
|
||||
return
|
||||
if not column_exists(table_name, column_name):
|
||||
return
|
||||
|
||||
# 检查当前列类型,如果已经是 TEXT 则跳过
|
||||
col_type = get_column_type(table_name, column_name)
|
||||
if col_type and "TEXT" in col_type:
|
||||
return
|
||||
|
||||
# 如果是 JSON 类型(可能是历史遗留),先将 JSON 数据转为文本表示再变更类型
|
||||
is_json_col = col_type and "JSON" in col_type
|
||||
|
||||
if is_json_col and not is_sqlite():
|
||||
# PostgreSQL: 先用 CAST 把 JSON 值转为 TEXT,保留数据
|
||||
op.execute(
|
||||
sa.text(
|
||||
f"ALTER TABLE {table_name} ALTER COLUMN {column_name} "
|
||||
f"TYPE TEXT USING {column_name}::TEXT"
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
if is_sqlite():
|
||||
# SQLite 不支持直接 ALTER COLUMN,需要用 batch 模式
|
||||
# batch 模式会自动处理 JSON->TEXT 的数据迁移
|
||||
with op.batch_alter_table(table_name) as batch_op:
|
||||
batch_op.alter_column(
|
||||
column_name,
|
||||
type_=sa.Text(),
|
||||
existing_type=sa.String(original_length) if original_length else sa.Text(),
|
||||
)
|
||||
else:
|
||||
op.alter_column(
|
||||
table_name,
|
||||
column_name,
|
||||
type_=sa.Text(),
|
||||
existing_type=sa.String(original_length) if original_length else sa.Text(),
|
||||
existing_nullable=True,
|
||||
)
|
||||
|
||||
|
||||
def shrink_column_to_varchar(
|
||||
table_name: str, column_name: str, target_length: int, nullable: bool = False
|
||||
) -> None:
|
||||
"""将 TEXT 列缩小为 VARCHAR(兼容 SQLite)
|
||||
WARNING: 如果数据超过 target_length 会失败
|
||||
"""
|
||||
if not table_exists(table_name):
|
||||
return
|
||||
if not column_exists(table_name, column_name):
|
||||
return
|
||||
|
||||
if is_sqlite():
|
||||
with op.batch_alter_table(table_name) as batch_op:
|
||||
batch_op.alter_column(
|
||||
column_name,
|
||||
type_=sa.String(target_length),
|
||||
existing_type=sa.Text(),
|
||||
existing_nullable=nullable,
|
||||
)
|
||||
else:
|
||||
op.alter_column(
|
||||
table_name,
|
||||
column_name,
|
||||
type_=sa.String(target_length),
|
||||
existing_type=sa.Text(),
|
||||
existing_nullable=nullable,
|
||||
)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Add providers.provider_type
|
||||
if not column_exists("providers", "provider_type"):
|
||||
op.add_column(
|
||||
"providers",
|
||||
sa.Column("provider_type", sa.String(20), nullable=False, server_default="custom"),
|
||||
)
|
||||
|
||||
# Add provider_api_keys.upstream_metadata
|
||||
if not column_exists("provider_api_keys", "upstream_metadata"):
|
||||
op.add_column(
|
||||
"provider_api_keys",
|
||||
sa.Column("upstream_metadata", sa.JSON(), nullable=True),
|
||||
)
|
||||
|
||||
# Add provider_api_keys.oauth_invalid_at
|
||||
if not column_exists("provider_api_keys", "oauth_invalid_at"):
|
||||
op.add_column(
|
||||
"provider_api_keys",
|
||||
sa.Column("oauth_invalid_at", sa.DateTime(timezone=True), nullable=True),
|
||||
)
|
||||
|
||||
# Add provider_api_keys.oauth_invalid_reason
|
||||
if not column_exists("provider_api_keys", "oauth_invalid_reason"):
|
||||
op.add_column(
|
||||
"provider_api_keys",
|
||||
sa.Column("oauth_invalid_reason", sa.String(255), nullable=True),
|
||||
)
|
||||
|
||||
# Expand VARCHAR columns to TEXT
|
||||
for table_name, column_name, original_length in COLUMNS_TO_EXPAND:
|
||||
expand_column_to_text(table_name, column_name, original_length)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Shrink TEXT columns back to VARCHAR
|
||||
# WARNING: Downgrade may fail if any values exceed original length
|
||||
for table_name, column_name, original_length in reversed(COLUMNS_TO_EXPAND):
|
||||
# 跳过没有原始长度的列(如 auth_config,由其他迁移创建)
|
||||
if original_length is None:
|
||||
continue
|
||||
shrink_column_to_varchar(table_name, column_name, original_length)
|
||||
|
||||
# Drop provider_api_keys.oauth_invalid_reason
|
||||
if column_exists("provider_api_keys", "oauth_invalid_reason"):
|
||||
op.drop_column("provider_api_keys", "oauth_invalid_reason")
|
||||
|
||||
# Drop provider_api_keys.oauth_invalid_at
|
||||
if column_exists("provider_api_keys", "oauth_invalid_at"):
|
||||
op.drop_column("provider_api_keys", "oauth_invalid_at")
|
||||
|
||||
# Drop provider_api_keys.upstream_metadata
|
||||
if column_exists("provider_api_keys", "upstream_metadata"):
|
||||
op.drop_column("provider_api_keys", "upstream_metadata")
|
||||
|
||||
# Drop providers.provider_type
|
||||
if column_exists("providers", "provider_type"):
|
||||
op.drop_column("providers", "provider_type")
|
||||
@@ -0,0 +1,254 @@
|
||||
"""Antigravity endpoint signature to gemini:chat & add proxy_nodes table (with manual fields)
|
||||
|
||||
Revision ID: e1b2c3d4f5a6
|
||||
Revises: b5c6d7e8f9a0
|
||||
Create Date: 2026-02-06 23:45:00.000000
|
||||
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy import inspect, text
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "e1b2c3d4f5a6"
|
||||
down_revision: str | None = "b5c6d7e8f9a0"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def table_exists(table_name: str) -> bool:
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
return table_name in inspector.get_table_names()
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
|
||||
# =========================================================================
|
||||
# Part 1: Antigravity endpoint signature migration (gemini:cli -> gemini:chat)
|
||||
# =========================================================================
|
||||
|
||||
# --- provider_endpoints ---
|
||||
# Update only when there is no conflicting gemini:chat endpoint for the same provider
|
||||
# (provider_endpoints has a unique constraint on (provider_id, api_format)).
|
||||
conn.execute(text("""
|
||||
UPDATE provider_endpoints pe
|
||||
SET
|
||||
api_format = 'gemini:chat',
|
||||
api_family = 'gemini',
|
||||
endpoint_kind = 'chat'
|
||||
WHERE pe.api_format = 'gemini:cli'
|
||||
AND pe.provider_id IN (
|
||||
SELECT id FROM providers WHERE lower(provider_type) = 'antigravity'
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM provider_endpoints pe2
|
||||
WHERE pe2.provider_id = pe.provider_id
|
||||
AND pe2.api_format = 'gemini:chat'
|
||||
)
|
||||
"""))
|
||||
|
||||
# Best-effort normalization for already-existing Antigravity gemini:chat endpoints.
|
||||
conn.execute(text("""
|
||||
UPDATE provider_endpoints pe
|
||||
SET
|
||||
api_family = 'gemini',
|
||||
endpoint_kind = 'chat'
|
||||
WHERE pe.api_format = 'gemini:chat'
|
||||
AND pe.provider_id IN (
|
||||
SELECT id FROM providers WHERE lower(provider_type) = 'antigravity'
|
||||
)
|
||||
"""))
|
||||
|
||||
# --- provider_api_keys.api_formats (JSON array) ---
|
||||
# Replace "gemini:cli" with "gemini:chat" in the JSON array for Antigravity keys.
|
||||
# Uses text-level replace on the serialized JSON -- safe because the value is a
|
||||
# simple string with no special characters that could cause ambiguous replacements.
|
||||
conn.execute(text("""
|
||||
UPDATE provider_api_keys pak
|
||||
SET api_formats = replace(pak.api_formats::text, '"gemini:cli"', '"gemini:chat"')::json
|
||||
WHERE pak.provider_id IN (
|
||||
SELECT id FROM providers WHERE lower(provider_type) = 'antigravity'
|
||||
)
|
||||
AND pak.api_formats IS NOT NULL
|
||||
AND pak.api_formats::text LIKE '%"gemini:cli"%'
|
||||
"""))
|
||||
|
||||
# =========================================================================
|
||||
# Part 2: Create proxy_nodes table with manual proxy fields (idempotent)
|
||||
# =========================================================================
|
||||
|
||||
# Create ENUM type (idempotent)
|
||||
op.execute(
|
||||
"DO $$ BEGIN "
|
||||
"CREATE TYPE proxynodestatus AS ENUM ('online', 'unhealthy', 'offline'); "
|
||||
"EXCEPTION WHEN duplicate_object THEN NULL; "
|
||||
"END $$"
|
||||
)
|
||||
|
||||
if table_exists("proxy_nodes"):
|
||||
# Table already exists — ensure manual proxy columns are present
|
||||
inspector = inspect(conn)
|
||||
existing_columns = {c["name"] for c in inspector.get_columns("proxy_nodes")}
|
||||
|
||||
# ip 列扩容:手动节点的 ip 存储 "socks5://hostname" 形式,45 字符可能不够
|
||||
ip_col = next((c for c in inspector.get_columns("proxy_nodes") if c["name"] == "ip"), None)
|
||||
if ip_col and hasattr(ip_col["type"], "length") and (ip_col["type"].length or 0) < 512:
|
||||
op.alter_column("proxy_nodes", "ip", type_=sa.String(512), existing_nullable=False)
|
||||
|
||||
manual_columns = [
|
||||
("is_manual", sa.Boolean(), False, sa.text("false"), "是否为手动添加的代理节点"),
|
||||
("proxy_url", sa.String(500), True, None, "手动节点的完整代理 URL"),
|
||||
("proxy_username", sa.String(255), True, None, "手动节点的代理用户名"),
|
||||
("proxy_password", sa.String(500), True, None, "手动节点的代理密码"),
|
||||
]
|
||||
for col_name, col_type, nullable, default, comment in manual_columns:
|
||||
if col_name not in existing_columns:
|
||||
op.add_column(
|
||||
"proxy_nodes",
|
||||
sa.Column(
|
||||
col_name,
|
||||
col_type, # type: ignore[arg-type]
|
||||
nullable=nullable,
|
||||
server_default=default,
|
||||
comment=comment,
|
||||
),
|
||||
)
|
||||
return
|
||||
|
||||
op.create_table(
|
||||
"proxy_nodes",
|
||||
sa.Column("id", sa.String(36), primary_key=True),
|
||||
sa.Column("name", sa.String(100), nullable=False),
|
||||
sa.Column("ip", sa.String(512), nullable=False),
|
||||
sa.Column("port", sa.Integer(), nullable=False),
|
||||
sa.Column("region", sa.String(100), nullable=True),
|
||||
sa.Column(
|
||||
"status",
|
||||
postgresql.ENUM(
|
||||
"online",
|
||||
"unhealthy",
|
||||
"offline",
|
||||
name="proxynodestatus",
|
||||
create_type=False,
|
||||
),
|
||||
nullable=False,
|
||||
server_default=sa.text("'online'"),
|
||||
),
|
||||
sa.Column(
|
||||
"registered_by",
|
||||
sa.String(36),
|
||||
sa.ForeignKey("users.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
),
|
||||
sa.Column("last_heartbeat_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("heartbeat_interval", sa.Integer(), nullable=False, server_default=sa.text("30")),
|
||||
sa.Column("active_connections", sa.Integer(), nullable=False, server_default=sa.text("0")),
|
||||
sa.Column("total_requests", sa.BigInteger(), nullable=False, server_default=sa.text("0")),
|
||||
sa.Column("avg_latency_ms", sa.Float(), nullable=True),
|
||||
# --- Manual proxy node fields ---
|
||||
sa.Column(
|
||||
"is_manual",
|
||||
sa.Boolean(),
|
||||
nullable=False,
|
||||
server_default=sa.text("false"),
|
||||
comment="是否为手动添加的代理节点",
|
||||
),
|
||||
sa.Column(
|
||||
"proxy_url",
|
||||
sa.String(500),
|
||||
nullable=True,
|
||||
comment="手动节点的完整代理 URL",
|
||||
),
|
||||
sa.Column(
|
||||
"proxy_username",
|
||||
sa.String(255),
|
||||
nullable=True,
|
||||
comment="手动节点的代理用户名",
|
||||
),
|
||||
sa.Column(
|
||||
"proxy_password",
|
||||
sa.String(500),
|
||||
nullable=True,
|
||||
comment="手动节点的代理密码",
|
||||
),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.text("CURRENT_TIMESTAMP"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.text("CURRENT_TIMESTAMP"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.UniqueConstraint("ip", "port", name="uq_proxy_node_ip_port"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
|
||||
# =========================================================================
|
||||
# Part 2 rollback: Drop proxy_nodes table (and manual columns if present)
|
||||
# =========================================================================
|
||||
if table_exists("proxy_nodes"):
|
||||
op.drop_table("proxy_nodes")
|
||||
|
||||
# Best-effort: drop type (only used by proxy_nodes)
|
||||
op.execute("DROP TYPE IF EXISTS proxynodestatus")
|
||||
|
||||
# =========================================================================
|
||||
# Part 1 rollback: Revert Antigravity endpoint signature (gemini:chat -> gemini:cli)
|
||||
# =========================================================================
|
||||
|
||||
# --- provider_endpoints ---
|
||||
conn.execute(text("""
|
||||
UPDATE provider_endpoints pe
|
||||
SET
|
||||
api_format = 'gemini:cli',
|
||||
api_family = 'gemini',
|
||||
endpoint_kind = 'cli'
|
||||
WHERE pe.api_format = 'gemini:chat'
|
||||
AND pe.provider_id IN (
|
||||
SELECT id FROM providers WHERE lower(provider_type) = 'antigravity'
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM provider_endpoints pe2
|
||||
WHERE pe2.provider_id = pe.provider_id
|
||||
AND pe2.api_format = 'gemini:cli'
|
||||
)
|
||||
"""))
|
||||
|
||||
# Best-effort normalization for already-existing Antigravity gemini:cli endpoints.
|
||||
conn.execute(text("""
|
||||
UPDATE provider_endpoints pe
|
||||
SET
|
||||
api_family = 'gemini',
|
||||
endpoint_kind = 'cli'
|
||||
WHERE pe.api_format = 'gemini:cli'
|
||||
AND pe.provider_id IN (
|
||||
SELECT id FROM providers WHERE lower(provider_type) = 'antigravity'
|
||||
)
|
||||
"""))
|
||||
|
||||
# --- provider_api_keys.api_formats (JSON array) ---
|
||||
conn.execute(text("""
|
||||
UPDATE provider_api_keys pak
|
||||
SET api_formats = replace(pak.api_formats::text, '"gemini:chat"', '"gemini:cli"')::json
|
||||
WHERE pak.provider_id IN (
|
||||
SELECT id FROM providers WHERE lower(provider_type) = 'antigravity'
|
||||
)
|
||||
AND pak.api_formats IS NOT NULL
|
||||
AND pak.api_formats::text LIKE '%"gemini:chat"%'
|
||||
"""))
|
||||
@@ -0,0 +1,61 @@
|
||||
"""Add remote_config and config_version to proxy_nodes
|
||||
|
||||
Revision ID: 3aff3ffc4a0e
|
||||
Revises: e1b2c3d4f5a6
|
||||
Create Date: 2026-02-07 15:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy import inspect
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "3aff3ffc4a0e"
|
||||
down_revision: str | None = "e1b2c3d4f5a6"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def column_exists(table_name: str, column_name: str) -> bool:
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
columns = [c["name"] for c in inspector.get_columns(table_name)]
|
||||
return column_name in columns
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
if not column_exists("proxy_nodes", "remote_config"):
|
||||
op.add_column(
|
||||
"proxy_nodes",
|
||||
sa.Column(
|
||||
"remote_config",
|
||||
sa.JSON(),
|
||||
nullable=True,
|
||||
comment="管理端下发的远程配置 (allowed_ports, log_level, heartbeat_interval, timestamp_tolerance)",
|
||||
),
|
||||
)
|
||||
|
||||
if not column_exists("proxy_nodes", "config_version"):
|
||||
op.add_column(
|
||||
"proxy_nodes",
|
||||
sa.Column(
|
||||
"config_version",
|
||||
sa.Integer(),
|
||||
nullable=False,
|
||||
server_default="0",
|
||||
comment="远程配置版本号,每次更新 +1",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
if column_exists("proxy_nodes", "config_version"):
|
||||
op.drop_column("proxy_nodes", "config_version")
|
||||
if column_exists("proxy_nodes", "remote_config"):
|
||||
op.drop_column("proxy_nodes", "remote_config")
|
||||
@@ -0,0 +1,61 @@
|
||||
"""Add tls_enabled and tls_cert_fingerprint to proxy_nodes
|
||||
|
||||
Revision ID: 4b5c6d7e8f9a
|
||||
Revises: 3aff3ffc4a0e
|
||||
Create Date: 2026-02-07 18:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy import inspect
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "4b5c6d7e8f9a"
|
||||
down_revision: str | None = "3aff3ffc4a0e"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def column_exists(table_name: str, column_name: str) -> bool:
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
columns = [c["name"] for c in inspector.get_columns(table_name)]
|
||||
return column_name in columns
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
if not column_exists("proxy_nodes", "tls_enabled"):
|
||||
op.add_column(
|
||||
"proxy_nodes",
|
||||
sa.Column(
|
||||
"tls_enabled",
|
||||
sa.Boolean(),
|
||||
nullable=False,
|
||||
server_default="false",
|
||||
comment="是否启用 TLS 加密",
|
||||
),
|
||||
)
|
||||
|
||||
if not column_exists("proxy_nodes", "tls_cert_fingerprint"):
|
||||
op.add_column(
|
||||
"proxy_nodes",
|
||||
sa.Column(
|
||||
"tls_cert_fingerprint",
|
||||
sa.String(128),
|
||||
nullable=True,
|
||||
comment="TLS 证书 SHA-256 指纹(hex)",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
if column_exists("proxy_nodes", "tls_cert_fingerprint"):
|
||||
op.drop_column("proxy_nodes", "tls_cert_fingerprint")
|
||||
if column_exists("proxy_nodes", "tls_enabled"):
|
||||
op.drop_column("proxy_nodes", "tls_enabled")
|
||||
@@ -0,0 +1,60 @@
|
||||
"""Add hardware_info and estimated_max_concurrency to proxy_nodes
|
||||
|
||||
Revision ID: 5c6d7e8f9a0b
|
||||
Revises: 4b5c6d7e8f9a
|
||||
Create Date: 2026-02-08 12:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy import inspect
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "5c6d7e8f9a0b"
|
||||
down_revision: str | None = "4b5c6d7e8f9a"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def column_exists(table_name: str, column_name: str) -> bool:
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
columns = [c["name"] for c in inspector.get_columns(table_name)]
|
||||
return column_name in columns
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
if not column_exists("proxy_nodes", "hardware_info"):
|
||||
op.add_column(
|
||||
"proxy_nodes",
|
||||
sa.Column(
|
||||
"hardware_info",
|
||||
sa.JSON(),
|
||||
nullable=True,
|
||||
comment="硬件信息 (cpu_cores, total_memory_mb, os_info, fd_limit, ...)",
|
||||
),
|
||||
)
|
||||
|
||||
if not column_exists("proxy_nodes", "estimated_max_concurrency"):
|
||||
op.add_column(
|
||||
"proxy_nodes",
|
||||
sa.Column(
|
||||
"estimated_max_concurrency",
|
||||
sa.Integer(),
|
||||
nullable=True,
|
||||
comment="基于硬件估算的最大并发连接数",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
if column_exists("proxy_nodes", "estimated_max_concurrency"):
|
||||
op.drop_column("proxy_nodes", "estimated_max_concurrency")
|
||||
if column_exists("proxy_nodes", "hardware_info"):
|
||||
op.drop_column("proxy_nodes", "hardware_info")
|
||||
@@ -0,0 +1,47 @@
|
||||
"""Add proxy column to provider_api_keys for per-key proxy configuration
|
||||
|
||||
Revision ID: 6d7e8f9a0b1c
|
||||
Revises: 5c6d7e8f9a0b
|
||||
Create Date: 2026-02-08 15:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy import inspect
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "6d7e8f9a0b1c"
|
||||
down_revision: str | None = "5c6d7e8f9a0b"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def column_exists(table_name: str, column_name: str) -> bool:
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
columns = [c["name"] for c in inspector.get_columns(table_name)]
|
||||
return column_name in columns
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
if not column_exists("provider_api_keys", "proxy"):
|
||||
op.add_column(
|
||||
"provider_api_keys",
|
||||
sa.Column(
|
||||
"proxy",
|
||||
sa.JSON(),
|
||||
nullable=True,
|
||||
comment="Key 级别代理配置(覆盖 Provider 级别代理),如 {node_id, enabled}",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
if column_exists("provider_api_keys", "proxy"):
|
||||
op.drop_column("provider_api_keys", "proxy")
|
||||
@@ -0,0 +1,46 @@
|
||||
"""Add provider_request_body and client_response_body columns to usage table
|
||||
|
||||
Revision ID: 7e8f9a0b1c2d
|
||||
Revises: 6d7e8f9a0b1c
|
||||
Create Date: 2026-02-20 18:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from sqlalchemy import text
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "7e8f9a0b1c2d"
|
||||
down_revision: str | None = "6d7e8f9a0b1c"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
# Use PostgreSQL native IF NOT EXISTS to avoid duplicate-column races
|
||||
# when migrations are triggered concurrently (e.g. startup + manual run).
|
||||
conn.execute(text("ALTER TABLE usage ADD COLUMN IF NOT EXISTS provider_request_body JSON"))
|
||||
conn.execute(
|
||||
text("ALTER TABLE usage ADD COLUMN IF NOT EXISTS provider_request_body_compressed BYTEA")
|
||||
)
|
||||
conn.execute(text("ALTER TABLE usage ADD COLUMN IF NOT EXISTS client_response_body JSON"))
|
||||
conn.execute(
|
||||
text("ALTER TABLE usage ADD COLUMN IF NOT EXISTS client_response_body_compressed BYTEA")
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
for col in (
|
||||
"client_response_body_compressed",
|
||||
"client_response_body",
|
||||
"provider_request_body_compressed",
|
||||
"provider_request_body",
|
||||
):
|
||||
conn.execute(text(f"ALTER TABLE usage DROP COLUMN IF EXISTS {col}"))
|
||||
@@ -0,0 +1,91 @@
|
||||
"""Add api_family and endpoint_kind columns to usage table
|
||||
|
||||
Revision ID: 8f9a0b1c2d3e
|
||||
Revises: 7e8f9a0b1c2d
|
||||
Create Date: 2026-02-21 15:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy import inspect, text
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "8f9a0b1c2d3e"
|
||||
down_revision: str | None = "7e8f9a0b1c2d"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
# Usage 表新增列
|
||||
NEW_COLUMNS = [
|
||||
("api_family", sa.String(50)),
|
||||
("endpoint_kind", sa.String(50)),
|
||||
("provider_api_family", sa.String(50)),
|
||||
("provider_endpoint_kind", sa.String(50)),
|
||||
]
|
||||
|
||||
# 新增索引
|
||||
NEW_INDEXES = [
|
||||
("idx_usage_api_family", "usage", ["api_family"]),
|
||||
("idx_usage_endpoint_kind", "usage", ["endpoint_kind"]),
|
||||
("idx_usage_family_kind", "usage", ["api_family", "endpoint_kind"]),
|
||||
]
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
|
||||
# 使用 PostgreSQL 原生 IF NOT EXISTS,比 inspect 更可靠(避免同一事务内缓存问题)
|
||||
col_definitions = {
|
||||
"api_family": "VARCHAR(50)",
|
||||
"endpoint_kind": "VARCHAR(50)",
|
||||
"provider_api_family": "VARCHAR(50)",
|
||||
"provider_endpoint_kind": "VARCHAR(50)",
|
||||
}
|
||||
for col_name, col_type_sql in col_definitions.items():
|
||||
conn.execute(text(f"ALTER TABLE usage ADD COLUMN IF NOT EXISTS {col_name} {col_type_sql}"))
|
||||
|
||||
# 数据迁移:从 api_format 解析 api_family + endpoint_kind
|
||||
conn.execute(text("""
|
||||
UPDATE usage SET
|
||||
api_family = lower(split_part(api_format, ':', 1)),
|
||||
endpoint_kind = lower(split_part(api_format, ':', 2))
|
||||
WHERE api_format IS NOT NULL
|
||||
AND api_format LIKE '%%:%%'
|
||||
AND api_family IS NULL
|
||||
"""))
|
||||
conn.execute(text("""
|
||||
UPDATE usage SET
|
||||
provider_api_family = lower(split_part(endpoint_api_format, ':', 1)),
|
||||
provider_endpoint_kind = lower(split_part(endpoint_api_format, ':', 2))
|
||||
WHERE endpoint_api_format IS NOT NULL
|
||||
AND endpoint_api_format LIKE '%%:%%'
|
||||
AND provider_api_family IS NULL
|
||||
"""))
|
||||
|
||||
# 创建索引
|
||||
inspector = inspect(conn)
|
||||
existing_indexes = {idx["name"] for idx in inspector.get_indexes("usage")}
|
||||
for idx_name, table, columns in NEW_INDEXES:
|
||||
if idx_name not in existing_indexes:
|
||||
op.create_index(idx_name, table, columns)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
inspector = inspect(conn)
|
||||
|
||||
existing_indexes = {idx["name"] for idx in inspector.get_indexes("usage")}
|
||||
for idx_name, _, _ in reversed(NEW_INDEXES):
|
||||
if idx_name in existing_indexes:
|
||||
op.drop_index(idx_name, table_name="usage")
|
||||
|
||||
existing_columns = {col["name"] for col in inspector.get_columns("usage")}
|
||||
for col_name, _ in reversed(NEW_COLUMNS):
|
||||
if col_name in existing_columns:
|
||||
op.drop_column("usage", col_name)
|
||||
@@ -0,0 +1,106 @@
|
||||
"""Add tunnel mode fields and remove IP forwarding fields
|
||||
|
||||
Revision ID: 9a0b1c2d3e4f
|
||||
Revises: 8f9a0b1c2d3e
|
||||
Create Date: 2026-02-24 17:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy import inspect
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "9a0b1c2d3e4f"
|
||||
down_revision: str | None = "8f9a0b1c2d3e"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def column_exists(table_name: str, column_name: str) -> bool:
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
columns = [c["name"] for c in inspector.get_columns(table_name)]
|
||||
return column_name in columns
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# 添加 tunnel 模式字段
|
||||
if not column_exists("proxy_nodes", "tunnel_mode"):
|
||||
op.add_column(
|
||||
"proxy_nodes",
|
||||
sa.Column(
|
||||
"tunnel_mode",
|
||||
sa.Boolean(),
|
||||
nullable=False,
|
||||
server_default=sa.text("false"),
|
||||
comment="是否使用 WebSocket 隧道模式",
|
||||
),
|
||||
)
|
||||
if not column_exists("proxy_nodes", "tunnel_connected"):
|
||||
op.add_column(
|
||||
"proxy_nodes",
|
||||
sa.Column(
|
||||
"tunnel_connected",
|
||||
sa.Boolean(),
|
||||
nullable=False,
|
||||
server_default=sa.text("false"),
|
||||
comment="隧道是否已连接",
|
||||
),
|
||||
)
|
||||
if not column_exists("proxy_nodes", "tunnel_connected_at"):
|
||||
op.add_column(
|
||||
"proxy_nodes",
|
||||
sa.Column(
|
||||
"tunnel_connected_at",
|
||||
sa.DateTime(timezone=True),
|
||||
nullable=True,
|
||||
comment="隧道最近一次建立时间",
|
||||
),
|
||||
)
|
||||
|
||||
# tunnel 模式节点不需要 port,将其置零
|
||||
op.execute("UPDATE proxy_nodes SET port = 0 WHERE tunnel_mode = true")
|
||||
|
||||
# 移除旧的 IP 转发字段
|
||||
if column_exists("proxy_nodes", "tls_enabled"):
|
||||
op.drop_column("proxy_nodes", "tls_enabled")
|
||||
if column_exists("proxy_nodes", "tls_cert_fingerprint"):
|
||||
op.drop_column("proxy_nodes", "tls_cert_fingerprint")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# 恢复 IP 转发字段
|
||||
if not column_exists("proxy_nodes", "tls_cert_fingerprint"):
|
||||
op.add_column(
|
||||
"proxy_nodes",
|
||||
sa.Column(
|
||||
"tls_cert_fingerprint",
|
||||
sa.String(128),
|
||||
nullable=True,
|
||||
comment="TLS 证书 SHA-256 指纹(hex)",
|
||||
),
|
||||
)
|
||||
if not column_exists("proxy_nodes", "tls_enabled"):
|
||||
op.add_column(
|
||||
"proxy_nodes",
|
||||
sa.Column(
|
||||
"tls_enabled",
|
||||
sa.Boolean(),
|
||||
nullable=False,
|
||||
server_default=sa.text("false"),
|
||||
comment="是否启用 TLS 加密",
|
||||
),
|
||||
)
|
||||
|
||||
# 移除 tunnel 模式字段
|
||||
if column_exists("proxy_nodes", "tunnel_connected_at"):
|
||||
op.drop_column("proxy_nodes", "tunnel_connected_at")
|
||||
if column_exists("proxy_nodes", "tunnel_connected"):
|
||||
op.drop_column("proxy_nodes", "tunnel_connected")
|
||||
if column_exists("proxy_nodes", "tunnel_mode"):
|
||||
op.drop_column("proxy_nodes", "tunnel_mode")
|
||||
@@ -0,0 +1,224 @@
|
||||
"""Add cache_creation columns, clean up capability settings, add user_model_usage_counts,
|
||||
enforce global_model_id NOT NULL
|
||||
|
||||
1. Add cache_creation_input_tokens_5m and cache_creation_input_tokens_1h to usage table.
|
||||
2. Clean up cache_1h/context_1m/gemini_files from user-configurable settings
|
||||
(now auto-detected via REQUEST_PARAM mode).
|
||||
3. Create user_model_usage_counts table for per-user per-model atomic usage counters.
|
||||
4. Enforce models.global_model_id NOT NULL (delete orphan models without global model).
|
||||
|
||||
Revision ID: b2c3d4e5f6a7
|
||||
Revises: 9a0b1c2d3e4f
|
||||
Create Date: 2026-02-28 14:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import uuid
|
||||
from collections.abc import Sequence
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy import inspect
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "b2c3d4e5f6a7"
|
||||
down_revision: str | None = "9a0b1c2d3e4f"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def column_exists(table_name: str, column_name: str) -> bool:
|
||||
bind = op.get_bind()
|
||||
insp = inspect(bind)
|
||||
columns = [c["name"] for c in insp.get_columns(table_name)]
|
||||
return column_name in columns
|
||||
|
||||
|
||||
def table_exists(table_name: str) -> bool:
|
||||
bind = op.get_bind()
|
||||
insp = inspect(bind)
|
||||
return table_name in insp.get_table_names()
|
||||
|
||||
|
||||
def index_exists(table_name: str, index_name: str) -> bool:
|
||||
bind = op.get_bind()
|
||||
insp = inspect(bind)
|
||||
return any(idx["name"] == index_name for idx in insp.get_indexes(table_name))
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# --- 1. Add cache_creation columns ---
|
||||
if not column_exists("usage", "cache_creation_input_tokens_5m"):
|
||||
op.add_column(
|
||||
"usage",
|
||||
sa.Column(
|
||||
"cache_creation_input_tokens_5m",
|
||||
sa.Integer(),
|
||||
nullable=False,
|
||||
server_default=sa.text("0"),
|
||||
comment="5min TTL cache creation input tokens",
|
||||
),
|
||||
)
|
||||
if not column_exists("usage", "cache_creation_input_tokens_1h"):
|
||||
op.add_column(
|
||||
"usage",
|
||||
sa.Column(
|
||||
"cache_creation_input_tokens_1h",
|
||||
sa.Integer(),
|
||||
nullable=False,
|
||||
server_default=sa.text("0"),
|
||||
comment="1h TTL cache creation input tokens",
|
||||
),
|
||||
)
|
||||
|
||||
# --- 2. Clean up stale capability settings (pure Python, DB-agnostic) ---
|
||||
stale_keys = {"cache_1h", "context_1m", "gemini_files"}
|
||||
conn = op.get_bind()
|
||||
|
||||
# ApiKey.force_capabilities: dict-like JSON, remove stale keys
|
||||
rows = conn.execute(
|
||||
sa.text("SELECT id, force_capabilities FROM api_keys WHERE force_capabilities IS NOT NULL")
|
||||
).fetchall()
|
||||
for row in rows:
|
||||
raw = row[1]
|
||||
if raw is None:
|
||||
continue
|
||||
data = raw if isinstance(raw, dict) else json.loads(raw)
|
||||
cleaned = {k: v for k, v in data.items() if k not in stale_keys}
|
||||
new_val = json.dumps(cleaned) if cleaned else None
|
||||
conn.execute(
|
||||
sa.text("UPDATE api_keys SET force_capabilities = :val WHERE id = :id"),
|
||||
{"val": new_val, "id": row[0]},
|
||||
)
|
||||
|
||||
# User.model_capability_settings: nested dict {model_key: {cap: val}}, remove stale keys
|
||||
rows = conn.execute(
|
||||
sa.text(
|
||||
"SELECT id, model_capability_settings FROM users"
|
||||
" WHERE model_capability_settings IS NOT NULL"
|
||||
)
|
||||
).fetchall()
|
||||
for row in rows:
|
||||
raw = row[1]
|
||||
if raw is None:
|
||||
continue
|
||||
data = raw if isinstance(raw, dict) else json.loads(raw)
|
||||
cleaned = {}
|
||||
for model_key, caps in data.items():
|
||||
cap_cleaned = {k: v for k, v in caps.items() if k not in stale_keys}
|
||||
if cap_cleaned:
|
||||
cleaned[model_key] = cap_cleaned
|
||||
new_val = json.dumps(cleaned) if cleaned else None
|
||||
conn.execute(
|
||||
sa.text("UPDATE users SET model_capability_settings = :val WHERE id = :id"),
|
||||
{"val": new_val, "id": row[0]},
|
||||
)
|
||||
|
||||
# GlobalModel.supported_capabilities: JSON array, remove stale entries
|
||||
rows = conn.execute(
|
||||
sa.text(
|
||||
"SELECT id, supported_capabilities FROM global_models"
|
||||
" WHERE supported_capabilities IS NOT NULL"
|
||||
)
|
||||
).fetchall()
|
||||
for row in rows:
|
||||
raw = row[1]
|
||||
if raw is None:
|
||||
continue
|
||||
data = raw if isinstance(raw, list) else json.loads(raw)
|
||||
cleaned = [c for c in data if c not in stale_keys]
|
||||
new_val = json.dumps(cleaned) if cleaned else None
|
||||
conn.execute(
|
||||
sa.text("UPDATE global_models SET supported_capabilities = :val WHERE id = :id"),
|
||||
{"val": new_val, "id": row[0]},
|
||||
)
|
||||
|
||||
# --- 3. Create user_model_usage_counts table ---
|
||||
if not table_exists("user_model_usage_counts"):
|
||||
op.create_table(
|
||||
"user_model_usage_counts",
|
||||
sa.Column("id", sa.String(36), primary_key=True),
|
||||
sa.Column(
|
||||
"user_id",
|
||||
sa.String(36),
|
||||
sa.ForeignKey("users.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("model", sa.String(100), nullable=False),
|
||||
sa.Column("usage_count", sa.Integer, nullable=False, server_default="0"),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=sa.func.now(),
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at",
|
||||
sa.DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=sa.func.now(),
|
||||
),
|
||||
sa.UniqueConstraint("user_id", "model", name="uq_user_model_usage_count"),
|
||||
)
|
||||
if not index_exists("user_model_usage_counts", "idx_user_model_usage_user"):
|
||||
op.create_index("idx_user_model_usage_user", "user_model_usage_counts", ["user_id"])
|
||||
if not index_exists("user_model_usage_counts", "idx_user_model_usage_model"):
|
||||
op.create_index("idx_user_model_usage_model", "user_model_usage_counts", ["model"])
|
||||
|
||||
# Backfill from existing usage records (truncate first for idempotency)
|
||||
conn.execute(sa.text("DELETE FROM user_model_usage_counts"))
|
||||
rows = conn.execute(
|
||||
sa.text(
|
||||
"SELECT user_id, model, COUNT(*) AS cnt FROM usage"
|
||||
" WHERE user_id IS NOT NULL GROUP BY user_id, model"
|
||||
)
|
||||
).fetchall()
|
||||
now = datetime.now(timezone.utc)
|
||||
for row in rows:
|
||||
conn.execute(
|
||||
sa.text(
|
||||
"INSERT INTO user_model_usage_counts"
|
||||
" (id, user_id, model, usage_count, created_at, updated_at)"
|
||||
" VALUES (:id, :user_id, :model, :cnt, :now, :now)"
|
||||
),
|
||||
{
|
||||
"id": str(uuid.uuid4()),
|
||||
"user_id": row[0],
|
||||
"model": row[1],
|
||||
"cnt": row[2],
|
||||
"now": now,
|
||||
},
|
||||
)
|
||||
|
||||
# --- 4. Enforce models.global_model_id NOT NULL ---
|
||||
conn = op.get_bind()
|
||||
insp = inspect(conn)
|
||||
model_cols = {c["name"]: c for c in insp.get_columns("models")}
|
||||
if model_cols.get("global_model_id", {}).get("nullable", True):
|
||||
op.execute("DELETE FROM models WHERE global_model_id IS NULL")
|
||||
op.alter_column("models", "global_model_id", existing_type=sa.String(36), nullable=False)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Revert models.global_model_id to nullable
|
||||
if column_exists("models", "global_model_id"):
|
||||
op.alter_column("models", "global_model_id", existing_type=sa.String(36), nullable=True)
|
||||
|
||||
# Drop user_model_usage_counts
|
||||
if table_exists("user_model_usage_counts"):
|
||||
if index_exists("user_model_usage_counts", "idx_user_model_usage_model"):
|
||||
op.drop_index("idx_user_model_usage_model", table_name="user_model_usage_counts")
|
||||
if index_exists("user_model_usage_counts", "idx_user_model_usage_user"):
|
||||
op.drop_index("idx_user_model_usage_user", table_name="user_model_usage_counts")
|
||||
op.drop_table("user_model_usage_counts")
|
||||
|
||||
# Drop cache_creation columns
|
||||
if column_exists("usage", "cache_creation_input_tokens_1h"):
|
||||
op.drop_column("usage", "cache_creation_input_tokens_1h")
|
||||
if column_exists("usage", "cache_creation_input_tokens_5m"):
|
||||
op.drop_column("usage", "cache_creation_input_tokens_5m")
|
||||
# capability settings cleanup is not reversible
|
||||
@@ -0,0 +1,153 @@
|
||||
"""proxy_node_metrics_and_events
|
||||
|
||||
Revision ID: 48afe197cc15
|
||||
Revises: b2c3d4e5f6a7
|
||||
Create Date: 2026-02-28 04:33:11.201185+00:00
|
||||
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy import inspect
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "48afe197cc15"
|
||||
down_revision = "b2c3d4e5f6a7"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _column_exists(table_name: str, column_name: str) -> bool:
|
||||
bind = op.get_bind()
|
||||
insp = inspect(bind)
|
||||
columns = [c["name"] for c in insp.get_columns(table_name)]
|
||||
return column_name in columns
|
||||
|
||||
|
||||
def _table_exists(table_name: str) -> bool:
|
||||
bind = op.get_bind()
|
||||
insp = inspect(bind)
|
||||
return table_name in insp.get_table_names()
|
||||
|
||||
|
||||
def _enum_has_value(enum_name: str, value: str) -> bool:
|
||||
"""检查 PostgreSQL 枚举类型是否包含指定值"""
|
||||
bind = op.get_bind()
|
||||
result = bind.execute(
|
||||
sa.text(
|
||||
"SELECT 1 FROM pg_enum e JOIN pg_type t ON e.enumtypid = t.oid"
|
||||
" WHERE t.typname = :enum_name AND e.enumlabel = :value"
|
||||
),
|
||||
{"enum_name": enum_name, "value": value},
|
||||
)
|
||||
return result.fetchone() is not None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# proxy_nodes: 将已废弃的 unhealthy 状态迁移为 offline,然后从枚举中移除
|
||||
if _enum_has_value("proxynodestatus", "unhealthy"):
|
||||
op.execute("UPDATE proxy_nodes SET status = 'offline' WHERE status = 'unhealthy'")
|
||||
op.execute("ALTER TYPE proxynodestatus RENAME TO proxynodestatus_old")
|
||||
op.execute("CREATE TYPE proxynodestatus AS ENUM ('online', 'offline')")
|
||||
# 必须先移除旧枚举类型的 DEFAULT,否则 ALTER TYPE 会因无法转换默认值而报错
|
||||
op.execute("ALTER TABLE proxy_nodes ALTER COLUMN status DROP DEFAULT")
|
||||
op.execute(
|
||||
"ALTER TABLE proxy_nodes ALTER COLUMN status TYPE proxynodestatus"
|
||||
" USING status::text::proxynodestatus"
|
||||
)
|
||||
op.execute("ALTER TABLE proxy_nodes ALTER COLUMN status SET DEFAULT 'online'::proxynodestatus")
|
||||
op.execute("DROP TYPE proxynodestatus_old")
|
||||
|
||||
# proxy_nodes: 新增错误指标字段
|
||||
if not _column_exists("proxy_nodes", "failed_requests"):
|
||||
op.add_column(
|
||||
"proxy_nodes",
|
||||
sa.Column(
|
||||
"failed_requests",
|
||||
sa.BigInteger(),
|
||||
nullable=False,
|
||||
server_default="0",
|
||||
comment="累计失败请求数",
|
||||
),
|
||||
)
|
||||
if not _column_exists("proxy_nodes", "dns_failures"):
|
||||
op.add_column(
|
||||
"proxy_nodes",
|
||||
sa.Column(
|
||||
"dns_failures",
|
||||
sa.BigInteger(),
|
||||
nullable=False,
|
||||
server_default="0",
|
||||
comment="累计 DNS 失败数",
|
||||
),
|
||||
)
|
||||
if not _column_exists("proxy_nodes", "stream_errors"):
|
||||
op.add_column(
|
||||
"proxy_nodes",
|
||||
sa.Column(
|
||||
"stream_errors",
|
||||
sa.BigInteger(),
|
||||
nullable=False,
|
||||
server_default="0",
|
||||
comment="累计流错误数",
|
||||
),
|
||||
)
|
||||
|
||||
# proxy_node_events: 连接事件表
|
||||
if not _table_exists("proxy_node_events"):
|
||||
op.create_table(
|
||||
"proxy_node_events",
|
||||
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
|
||||
sa.Column("node_id", sa.String(length=36), nullable=False),
|
||||
sa.Column(
|
||||
"event_type",
|
||||
sa.String(length=20),
|
||||
nullable=False,
|
||||
comment="事件类型: connected, disconnected, error",
|
||||
),
|
||||
sa.Column(
|
||||
"detail",
|
||||
sa.String(length=500),
|
||||
nullable=True,
|
||||
comment="事件详情(如断开原因)",
|
||||
),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(["node_id"], ["proxy_nodes.id"], ondelete="CASCADE"),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index(
|
||||
"idx_proxy_node_events_node_created",
|
||||
"proxy_node_events",
|
||||
["node_id", "created_at"],
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_proxy_node_events_node_id"),
|
||||
"proxy_node_events",
|
||||
["node_id"],
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# 恢复 proxynodestatus 枚举,加回 unhealthy
|
||||
if not _enum_has_value("proxynodestatus", "unhealthy"):
|
||||
op.execute("ALTER TYPE proxynodestatus RENAME TO proxynodestatus_old")
|
||||
op.execute("CREATE TYPE proxynodestatus AS ENUM ('online', 'unhealthy', 'offline')")
|
||||
op.execute("ALTER TABLE proxy_nodes ALTER COLUMN status DROP DEFAULT")
|
||||
op.execute(
|
||||
"ALTER TABLE proxy_nodes ALTER COLUMN status TYPE proxynodestatus"
|
||||
" USING status::text::proxynodestatus"
|
||||
)
|
||||
op.execute("ALTER TABLE proxy_nodes ALTER COLUMN status SET DEFAULT 'online'::proxynodestatus")
|
||||
op.execute("DROP TYPE proxynodestatus_old")
|
||||
|
||||
if _table_exists("proxy_node_events"):
|
||||
op.drop_index(op.f("ix_proxy_node_events_node_id"), table_name="proxy_node_events")
|
||||
op.drop_index("idx_proxy_node_events_node_created", table_name="proxy_node_events")
|
||||
op.drop_table("proxy_node_events")
|
||||
if _column_exists("proxy_nodes", "stream_errors"):
|
||||
op.drop_column("proxy_nodes", "stream_errors")
|
||||
if _column_exists("proxy_nodes", "dns_failures"):
|
||||
op.drop_column("proxy_nodes", "dns_failures")
|
||||
if _column_exists("proxy_nodes", "failed_requests"):
|
||||
op.drop_column("proxy_nodes", "failed_requests")
|
||||
@@ -0,0 +1,49 @@
|
||||
"""add_request_candidates_composite_indexes
|
||||
|
||||
Revision ID: 00b9161b8729
|
||||
Revises: 48afe197cc15
|
||||
Create Date: 2026-02-28 14:48:00.000000+00:00
|
||||
|
||||
"""
|
||||
|
||||
from sqlalchemy import inspect
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "00b9161b8729"
|
||||
down_revision = "48afe197cc15"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _index_exists(index_name: str) -> bool:
|
||||
bind = op.get_bind()
|
||||
insp = inspect(bind)
|
||||
indexes = insp.get_indexes("request_candidates")
|
||||
return any(idx["name"] == index_name for idx in indexes)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# (request_id, status) - fallback/retry 查询优化
|
||||
if not _index_exists("idx_rc_request_id_status"):
|
||||
op.create_index(
|
||||
"idx_rc_request_id_status",
|
||||
"request_candidates",
|
||||
["request_id", "status"],
|
||||
)
|
||||
|
||||
# (provider_id, status, created_at) - provider 聚合统计优化
|
||||
if not _index_exists("idx_rc_provider_status_created"):
|
||||
op.create_index(
|
||||
"idx_rc_provider_status_created",
|
||||
"request_candidates",
|
||||
["provider_id", "status", "created_at"],
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
if _index_exists("idx_rc_provider_status_created"):
|
||||
op.drop_index("idx_rc_provider_status_created", table_name="request_candidates")
|
||||
if _index_exists("idx_rc_request_id_status"):
|
||||
op.drop_index("idx_rc_request_id_status", table_name="request_candidates")
|
||||
@@ -0,0 +1,263 @@
|
||||
"""vertex_ai_provider_type
|
||||
|
||||
Migrate legacy Vertex auth_type/provider_type into the new model:
|
||||
- provider_type=vertex_ai
|
||||
- auth_type=service_account (legacy vertex_ai renamed)
|
||||
- fixed Vertex endpoints: gemini:chat + claude:chat
|
||||
|
||||
Revision ID: 2a624af8dd3a
|
||||
Revises: 00b9161b8729
|
||||
Create Date: 2026-02-28 15:00:00.000000+00:00
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "2a624af8dd3a"
|
||||
down_revision = "00b9161b8729"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
_VERTEX_BASE_URL = "https://aiplatform.googleapis.com"
|
||||
_VERTEX_ENDPOINTS: tuple[tuple[str, str, str], ...] = (
|
||||
("gemini:chat", "gemini", "chat"),
|
||||
("claude:chat", "claude", "chat"),
|
||||
)
|
||||
_VERTEX_KEY_FORMATS_SA = '["gemini:chat","claude:chat"]'
|
||||
_VERTEX_KEY_FORMATS_API_KEY = '["gemini:chat"]'
|
||||
|
||||
|
||||
def _select_vertex_provider_ids(conn: sa.Connection) -> list[str]:
|
||||
"""Collect providers that should be treated as Vertex after migration."""
|
||||
rows = conn.execute(sa.text("""
|
||||
SELECT DISTINCT p.id
|
||||
FROM providers p
|
||||
LEFT JOIN provider_api_keys pak ON pak.provider_id = p.id
|
||||
WHERE lower(COALESCE(p.provider_type, '')) = 'vertex_ai'
|
||||
OR pak.auth_type = 'vertex_ai'
|
||||
"""))
|
||||
return [str(row[0]) for row in rows if row[0]]
|
||||
|
||||
|
||||
def _ensure_fixed_vertex_endpoints(conn: sa.Connection, provider_ids: list[str]) -> None:
|
||||
"""Ensure every Vertex provider has fixed gemini:chat + claude:chat endpoints."""
|
||||
for provider_id in provider_ids:
|
||||
provider_max_retries = (
|
||||
conn.execute(
|
||||
sa.text("""
|
||||
SELECT COALESCE(max_retries, 2)
|
||||
FROM providers
|
||||
WHERE id = :provider_id
|
||||
"""),
|
||||
{"provider_id": provider_id},
|
||||
).scalar()
|
||||
or 2
|
||||
)
|
||||
|
||||
for api_format, api_family, endpoint_kind in _VERTEX_ENDPOINTS:
|
||||
# Normalize existing fixed endpoint fields.
|
||||
conn.execute(
|
||||
sa.text("""
|
||||
UPDATE provider_endpoints
|
||||
SET
|
||||
api_family = :api_family,
|
||||
endpoint_kind = :endpoint_kind,
|
||||
base_url = :base_url,
|
||||
custom_path = NULL,
|
||||
is_active = TRUE,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE provider_id = :provider_id
|
||||
AND api_format = :api_format
|
||||
"""),
|
||||
{
|
||||
"provider_id": provider_id,
|
||||
"api_format": api_format,
|
||||
"api_family": api_family,
|
||||
"endpoint_kind": endpoint_kind,
|
||||
"base_url": _VERTEX_BASE_URL,
|
||||
},
|
||||
)
|
||||
|
||||
exists = conn.execute(
|
||||
sa.text("""
|
||||
SELECT 1
|
||||
FROM provider_endpoints
|
||||
WHERE provider_id = :provider_id
|
||||
AND api_format = :api_format
|
||||
LIMIT 1
|
||||
"""),
|
||||
{"provider_id": provider_id, "api_format": api_format},
|
||||
).first()
|
||||
|
||||
if not exists:
|
||||
conn.execute(
|
||||
sa.text("""
|
||||
INSERT INTO provider_endpoints (
|
||||
id,
|
||||
provider_id,
|
||||
api_format,
|
||||
api_family,
|
||||
endpoint_kind,
|
||||
base_url,
|
||||
custom_path,
|
||||
header_rules,
|
||||
body_rules,
|
||||
max_retries,
|
||||
is_active,
|
||||
config,
|
||||
format_acceptance_config,
|
||||
proxy,
|
||||
created_at,
|
||||
updated_at
|
||||
)
|
||||
VALUES (
|
||||
:id,
|
||||
:provider_id,
|
||||
:api_format,
|
||||
:api_family,
|
||||
:endpoint_kind,
|
||||
:base_url,
|
||||
NULL,
|
||||
NULL,
|
||||
NULL,
|
||||
:max_retries,
|
||||
TRUE,
|
||||
NULL,
|
||||
NULL,
|
||||
NULL,
|
||||
CURRENT_TIMESTAMP,
|
||||
CURRENT_TIMESTAMP
|
||||
)
|
||||
"""),
|
||||
{
|
||||
"id": str(uuid.uuid4()),
|
||||
"provider_id": provider_id,
|
||||
"api_format": api_format,
|
||||
"api_family": api_family,
|
||||
"endpoint_kind": endpoint_kind,
|
||||
"base_url": _VERTEX_BASE_URL,
|
||||
"max_retries": int(provider_max_retries),
|
||||
},
|
||||
)
|
||||
|
||||
# Vertex fixed-provider model: disable non-fixed endpoints.
|
||||
conn.execute(
|
||||
sa.text("""
|
||||
UPDATE provider_endpoints
|
||||
SET
|
||||
is_active = FALSE,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE provider_id = :provider_id
|
||||
AND api_format NOT IN ('gemini:chat', 'claude:chat')
|
||||
"""),
|
||||
{"provider_id": provider_id},
|
||||
)
|
||||
|
||||
|
||||
def _normalize_vertex_key_formats(conn: sa.Connection, provider_ids: list[str]) -> None:
|
||||
"""Normalize key.api_formats for Vertex keys by auth type."""
|
||||
for provider_id in provider_ids:
|
||||
# Service Account (and legacy vertex_ai) keys: allow Gemini + Claude models.
|
||||
conn.execute(
|
||||
sa.text("""
|
||||
UPDATE provider_api_keys
|
||||
SET
|
||||
api_formats = CAST(:api_formats AS json),
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE provider_id = :provider_id
|
||||
AND auth_type IN ('service_account', 'vertex_ai')
|
||||
"""),
|
||||
{
|
||||
"provider_id": provider_id,
|
||||
"api_formats": _VERTEX_KEY_FORMATS_SA,
|
||||
},
|
||||
)
|
||||
|
||||
# API Key mode on Vertex 仅支持 Gemini(Google publisher)。
|
||||
conn.execute(
|
||||
sa.text("""
|
||||
UPDATE provider_api_keys
|
||||
SET
|
||||
api_formats = CAST(:api_formats AS json),
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE provider_id = :provider_id
|
||||
AND auth_type = 'api_key'
|
||||
"""),
|
||||
{
|
||||
"provider_id": provider_id,
|
||||
"api_formats": _VERTEX_KEY_FORMATS_API_KEY,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
|
||||
# 1) 收集目标 Provider(兼容重复执行,先识别 legacy/new 两种来源)。
|
||||
provider_ids = _select_vertex_provider_ids(conn)
|
||||
|
||||
# 2) 先重命名 auth_type(legacy vertex_ai -> service_account)。
|
||||
conn.execute(sa.text("""
|
||||
UPDATE provider_api_keys
|
||||
SET auth_type = 'service_account'
|
||||
WHERE auth_type = 'vertex_ai'
|
||||
"""))
|
||||
|
||||
if not provider_ids:
|
||||
return
|
||||
|
||||
# 3) 归一 provider_type,并启用格式转换(Vertex 同时承载 Gemini/Claude)。
|
||||
for provider_id in provider_ids:
|
||||
conn.execute(
|
||||
sa.text("""
|
||||
UPDATE providers
|
||||
SET
|
||||
provider_type = 'vertex_ai',
|
||||
enable_format_conversion = TRUE
|
||||
WHERE id = :provider_id
|
||||
"""),
|
||||
{"provider_id": provider_id},
|
||||
)
|
||||
|
||||
# 4) 固定端点落地:gemini:chat + claude:chat。
|
||||
_ensure_fixed_vertex_endpoints(conn, provider_ids)
|
||||
|
||||
# 5) 归一 key 的 api_formats,避免调度命中旧格式。
|
||||
_normalize_vertex_key_formats(conn, provider_ids)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
|
||||
provider_rows = conn.execute(sa.text("""
|
||||
SELECT id
|
||||
FROM providers
|
||||
WHERE lower(COALESCE(provider_type, '')) = 'vertex_ai'
|
||||
"""))
|
||||
provider_ids = [str(row[0]) for row in provider_rows if row[0]]
|
||||
|
||||
if provider_ids:
|
||||
for provider_id in provider_ids:
|
||||
conn.execute(
|
||||
sa.text("""
|
||||
UPDATE provider_api_keys
|
||||
SET auth_type = 'vertex_ai'
|
||||
WHERE provider_id = :provider_id
|
||||
AND auth_type = 'service_account'
|
||||
"""),
|
||||
{"provider_id": provider_id},
|
||||
)
|
||||
conn.execute(
|
||||
sa.text("""
|
||||
UPDATE providers
|
||||
SET provider_type = 'custom'
|
||||
WHERE id = :provider_id
|
||||
"""),
|
||||
{"provider_id": provider_id},
|
||||
)
|
||||
@@ -0,0 +1,199 @@
|
||||
"""backfill_codex_compact_endpoint
|
||||
|
||||
Backfill Codex reverse-proxy endpoints:
|
||||
- ensure `openai:cli` endpoint is pinned to force_stream
|
||||
- ensure `openai:compact` endpoint exists
|
||||
|
||||
Revision ID: f0c3a7b9d1e2
|
||||
Revises: 2a624af8dd3a
|
||||
Create Date: 2026-03-01 17:00:00.000000+00:00
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "f0c3a7b9d1e2"
|
||||
down_revision = "2a624af8dd3a"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
_CODEX_BASE_URL = "https://chatgpt.com/backend-api/codex"
|
||||
_COMPACT_FORMAT = "openai:compact"
|
||||
_CLI_FORMAT = "openai:cli"
|
||||
_FORCE_STREAM = "force_stream"
|
||||
|
||||
|
||||
def _find_codex_provider_ids(conn: sa.Connection) -> list[str]:
|
||||
"""Find Codex providers (by provider_type or legacy base_url pattern)."""
|
||||
rows = conn.execute(sa.text("""
|
||||
SELECT DISTINCT p.id
|
||||
FROM providers p
|
||||
LEFT JOIN provider_endpoints pe ON pe.provider_id = p.id
|
||||
WHERE lower(COALESCE(p.provider_type, '')) = 'codex'
|
||||
OR (
|
||||
lower(COALESCE(pe.api_format, '')) = 'openai:cli'
|
||||
AND lower(COALESCE(pe.base_url, '')) LIKE '%/backend-api/codex%'
|
||||
)
|
||||
"""))
|
||||
return [str(r[0]) for r in rows if r[0]]
|
||||
|
||||
|
||||
def _get_cli_endpoint(conn: sa.Connection, provider_id: str) -> dict[str, Any] | None:
|
||||
"""Load existing openai:cli endpoint for the provider."""
|
||||
row = (
|
||||
conn.execute(
|
||||
sa.text("""
|
||||
SELECT base_url, header_rules, body_rules, max_retries, proxy, config
|
||||
FROM provider_endpoints
|
||||
WHERE provider_id = :pid AND api_format = :fmt
|
||||
LIMIT 1
|
||||
"""),
|
||||
{"pid": provider_id, "fmt": _CLI_FORMAT},
|
||||
)
|
||||
.mappings()
|
||||
.first()
|
||||
)
|
||||
return dict(row) if row else None
|
||||
|
||||
|
||||
def _pin_cli_force_stream(conn: sa.Connection, provider_id: str, cli: dict[str, Any]) -> None:
|
||||
"""Set upstream_stream_policy=force_stream on existing cli endpoint."""
|
||||
cfg = dict(cli.get("config") or {}) if isinstance(cli.get("config"), dict) else {}
|
||||
cfg.pop("upstreamStreamPolicy", None)
|
||||
cfg.pop("upstream_stream", None)
|
||||
cfg["upstream_stream_policy"] = _FORCE_STREAM
|
||||
conn.execute(
|
||||
sa.text("""
|
||||
UPDATE provider_endpoints
|
||||
SET api_family = 'openai',
|
||||
endpoint_kind = 'cli',
|
||||
config = CAST(:config AS json),
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE provider_id = :pid AND api_format = :fmt
|
||||
"""),
|
||||
{
|
||||
"pid": provider_id,
|
||||
"fmt": _CLI_FORMAT,
|
||||
"config": json.dumps(cfg, ensure_ascii=False),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _ensure_compact_endpoint(conn: sa.Connection, provider_id: str, cli: dict[str, Any]) -> None:
|
||||
"""Create openai:compact endpoint if missing (clone from cli)."""
|
||||
exists = conn.execute(
|
||||
sa.text(
|
||||
"SELECT 1 FROM provider_endpoints WHERE provider_id = :pid AND api_format = :fmt LIMIT 1"
|
||||
),
|
||||
{"pid": provider_id, "fmt": _COMPACT_FORMAT},
|
||||
).first()
|
||||
if exists:
|
||||
# Already exists, just ensure api_family/endpoint_kind are set.
|
||||
conn.execute(
|
||||
sa.text("""
|
||||
UPDATE provider_endpoints
|
||||
SET api_family = 'openai', endpoint_kind = 'compact',
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE provider_id = :pid AND api_format = :fmt
|
||||
"""),
|
||||
{"pid": provider_id, "fmt": _COMPACT_FORMAT},
|
||||
)
|
||||
return
|
||||
|
||||
# Clone from cli endpoint, strip stream policy.
|
||||
cfg = dict(cli.get("config") or {}) if isinstance(cli.get("config"), dict) else {}
|
||||
for k in ("upstream_stream_policy", "upstreamStreamPolicy", "upstream_stream"):
|
||||
cfg.pop(k, None)
|
||||
|
||||
def _json(val: Any) -> str | None:
|
||||
return json.dumps(val, ensure_ascii=False) if val is not None else None
|
||||
|
||||
conn.execute(
|
||||
sa.text("""
|
||||
INSERT INTO provider_endpoints (
|
||||
id, provider_id, api_format, api_family, endpoint_kind,
|
||||
base_url, custom_path, header_rules, body_rules,
|
||||
max_retries, is_active, config, format_acceptance_config,
|
||||
proxy, created_at, updated_at
|
||||
) VALUES (
|
||||
:id, :pid, :fmt, 'openai', 'compact',
|
||||
:base_url, NULL, CAST(:header_rules AS json), CAST(:body_rules AS json),
|
||||
:max_retries, TRUE, CAST(:config AS json), NULL,
|
||||
CAST(:proxy AS jsonb), CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
|
||||
)
|
||||
"""),
|
||||
{
|
||||
"id": str(uuid.uuid4()),
|
||||
"pid": provider_id,
|
||||
"fmt": _COMPACT_FORMAT,
|
||||
"base_url": cli.get("base_url") or _CODEX_BASE_URL,
|
||||
"header_rules": _json(cli.get("header_rules")),
|
||||
"body_rules": _json(cli.get("body_rules")),
|
||||
"max_retries": cli.get("max_retries") or 2,
|
||||
"config": _json(cfg or None),
|
||||
"proxy": _json(cli.get("proxy")),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _add_compact_to_key_formats(conn: sa.Connection, provider_id: str) -> None:
|
||||
"""Ensure provider keys include openai:compact in api_formats."""
|
||||
rows = (
|
||||
conn.execute(
|
||||
sa.text("SELECT id, api_formats FROM provider_api_keys WHERE provider_id = :pid"),
|
||||
{"pid": provider_id},
|
||||
)
|
||||
.mappings()
|
||||
.all()
|
||||
)
|
||||
for row in rows:
|
||||
raw = row["api_formats"]
|
||||
formats: list[str] = []
|
||||
if isinstance(raw, list):
|
||||
for item in raw:
|
||||
v = str(item or "").strip().lower()
|
||||
if v and v not in formats:
|
||||
formats.append(v)
|
||||
|
||||
if _COMPACT_FORMAT in formats:
|
||||
continue
|
||||
|
||||
# Insert compact right after cli, or at end.
|
||||
if _CLI_FORMAT in formats:
|
||||
idx = formats.index(_CLI_FORMAT) + 1
|
||||
formats.insert(idx, _COMPACT_FORMAT)
|
||||
else:
|
||||
formats.append(_COMPACT_FORMAT)
|
||||
|
||||
conn.execute(
|
||||
sa.text("""
|
||||
UPDATE provider_api_keys
|
||||
SET api_formats = CAST(:fmts AS json), updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = :id
|
||||
"""),
|
||||
{"id": row["id"], "fmts": json.dumps(formats, ensure_ascii=False)},
|
||||
)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
for provider_id in _find_codex_provider_ids(conn):
|
||||
cli = _get_cli_endpoint(conn, provider_id)
|
||||
if not cli:
|
||||
continue # No cli endpoint to clone from; skip.
|
||||
_pin_cli_force_stream(conn, provider_id, cli)
|
||||
_ensure_compact_endpoint(conn, provider_id, cli)
|
||||
_add_compact_to_key_formats(conn, provider_id)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Data backfill: no-op to avoid deleting user-managed data.
|
||||
return
|
||||
@@ -0,0 +1,29 @@
|
||||
"""add_proxy_metadata_to_proxy_nodes
|
||||
|
||||
Revision ID: 1d2e3f4a5b6c
|
||||
Revises: f0c3a7b9d1e2
|
||||
Create Date: 2026-03-02 13:00:00.000000+00:00
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "1d2e3f4a5b6c"
|
||||
down_revision: str | None = "f0c3a7b9d1e2"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute("ALTER TABLE public.proxy_nodes ADD COLUMN IF NOT EXISTS proxy_metadata json")
|
||||
op.execute(
|
||||
"COMMENT ON COLUMN public.proxy_nodes.proxy_metadata IS 'aether-proxy 上报元数据(版本等)'"
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("ALTER TABLE public.proxy_nodes DROP COLUMN IF EXISTS proxy_metadata")
|
||||
@@ -0,0 +1,72 @@
|
||||
"""backfill_codex_default_body_rules
|
||||
|
||||
Backfill default body_rules for codex providers with openai:cli endpoints
|
||||
that currently have body_rules IS NULL.
|
||||
|
||||
Rules:
|
||||
- drop max_output_tokens
|
||||
- drop temperature
|
||||
- drop top_p
|
||||
- set store = false
|
||||
- set instructions = "You are GPT-5." (when instructions not exists)
|
||||
|
||||
Revision ID: dd0278c0a28c
|
||||
Revises: 1d2e3f4a5b6c
|
||||
Create Date: 2026-03-02 15:00:00.000000+00:00
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "dd0278c0a28c"
|
||||
down_revision = "1d2e3f4a5b6c"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
_TARGET_FORMATS = ("openai:cli",)
|
||||
|
||||
_DEFAULT_BODY_RULES = [
|
||||
{"action": "drop", "path": "max_output_tokens"},
|
||||
{"action": "drop", "path": "temperature"},
|
||||
{"action": "drop", "path": "top_p"},
|
||||
{"action": "set", "path": "store", "value": False},
|
||||
{
|
||||
"action": "set",
|
||||
"path": "instructions",
|
||||
"value": "You are GPT-5.",
|
||||
"condition": {"path": "instructions", "op": "not_exists"},
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
|
||||
# 幂等性: 仅回填 codex 提供商中 body_rules 为空(SQL NULL 或 JSON null)的记录
|
||||
rules_json = json.dumps(_DEFAULT_BODY_RULES, ensure_ascii=False)
|
||||
result = conn.execute(
|
||||
sa.text("""
|
||||
UPDATE provider_endpoints pe
|
||||
SET body_rules = CAST(:rules AS json),
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
FROM providers p
|
||||
WHERE pe.provider_id = p.id
|
||||
AND p.provider_type = :ptype
|
||||
AND pe.api_format = :fmt
|
||||
AND (pe.body_rules IS NULL OR pe.body_rules::text = 'null')
|
||||
"""),
|
||||
{"rules": rules_json, "ptype": "codex", "fmt": _TARGET_FORMATS[0]},
|
||||
)
|
||||
if result.rowcount:
|
||||
print(f" backfilled body_rules for {result.rowcount} endpoint(s)")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Data backfill: no-op to avoid removing user-customized rules.
|
||||
return
|
||||
@@ -0,0 +1,45 @@
|
||||
"""add_idx_usage_provider_key
|
||||
|
||||
Add composite index on usage(provider_id, provider_api_key_id) to support
|
||||
the pool management page's per-key usage stats aggregation query.
|
||||
|
||||
Revision ID: 0ba031f328de
|
||||
Revises: dd0278c0a28c
|
||||
Create Date: 2026-03-03 10:00:00.000000+00:00
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "0ba031f328de"
|
||||
down_revision = "dd0278c0a28c"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
INDEX_NAME = "idx_usage_provider_key"
|
||||
TABLE = "usage"
|
||||
COLUMNS = ["provider_id", "provider_api_key_id"]
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
result = bind.execute(
|
||||
sa.text("SELECT 1 FROM pg_indexes WHERE indexname = :name"),
|
||||
{"name": INDEX_NAME},
|
||||
).fetchone()
|
||||
if result:
|
||||
return
|
||||
op.create_index(INDEX_NAME, TABLE, COLUMNS)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
result = bind.execute(
|
||||
sa.text("SELECT 1 FROM pg_indexes WHERE indexname = :name"),
|
||||
{"name": INDEX_NAME},
|
||||
).fetchone()
|
||||
if not result:
|
||||
return
|
||||
op.drop_index(INDEX_NAME, table_name=TABLE)
|
||||
@@ -0,0 +1,45 @@
|
||||
"""add_idx_usage_status_user_created
|
||||
|
||||
Add composite index on usage(status, user_id, created_at) to speed up
|
||||
interval timeline and active usage analytics queries.
|
||||
|
||||
Revision ID: 5f1d2e3c4b5a
|
||||
Revises: 0ba031f328de
|
||||
Create Date: 2026-03-03 17:30:00.000000+00:00
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "5f1d2e3c4b5a"
|
||||
down_revision = "0ba031f328de"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
INDEX_NAME = "idx_usage_status_user_created"
|
||||
TABLE = "usage"
|
||||
COLUMNS = ["status", "user_id", "created_at"]
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
result = bind.execute(
|
||||
sa.text("SELECT 1 FROM pg_indexes WHERE indexname = :name"),
|
||||
{"name": INDEX_NAME},
|
||||
).fetchone()
|
||||
if result:
|
||||
return
|
||||
op.create_index(INDEX_NAME, TABLE, COLUMNS)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
result = bind.execute(
|
||||
sa.text("SELECT 1 FROM pg_indexes WHERE indexname = :name"),
|
||||
{"name": INDEX_NAME},
|
||||
).fetchone()
|
||||
if not result:
|
||||
return
|
||||
op.drop_index(INDEX_NAME, table_name=TABLE)
|
||||
@@ -0,0 +1,41 @@
|
||||
"""add fingerprint column to provider_api_keys
|
||||
|
||||
Revision ID: 6a9b8c7d5e4f
|
||||
Revises: 5f1d2e3c4b5a
|
||||
Create Date: 2026-03-04 23:50:00.000000
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy import inspect
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "6a9b8c7d5e4f"
|
||||
down_revision: str | None = "5f1d2e3c4b5a"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def column_exists(table_name: str, column_name: str) -> bool:
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
columns = [c["name"] for c in inspector.get_columns(table_name)]
|
||||
return column_name in columns
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
if not column_exists("provider_api_keys", "fingerprint"):
|
||||
op.add_column(
|
||||
"provider_api_keys",
|
||||
sa.Column("fingerprint", sa.JSON(), nullable=True),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
if column_exists("provider_api_keys", "fingerprint"):
|
||||
op.drop_column("provider_api_keys", "fingerprint")
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,65 @@
|
||||
"""remove standalone api key locking
|
||||
|
||||
Revision ID: 7c91d2e4f8a1
|
||||
Revises: 6f7a8b9c0d1e
|
||||
Create Date: 2026-03-05 17:00:00.000000+00:00
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy import inspect
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "7c91d2e4f8a1"
|
||||
down_revision: str | None = "6f7a8b9c0d1e"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
_CONSTRAINT_NAME = "ck_api_keys_standalone_not_locked"
|
||||
|
||||
|
||||
def _column_exists(table_name: str, column_name: str) -> bool:
|
||||
bind = op.get_bind()
|
||||
insp = inspect(bind)
|
||||
insp.clear_cache()
|
||||
return column_name in [c["name"] for c in insp.get_columns(table_name)]
|
||||
|
||||
|
||||
def _check_constraint_exists(table_name: str, constraint_name: str) -> bool:
|
||||
bind = op.get_bind()
|
||||
insp = inspect(bind)
|
||||
insp.clear_cache()
|
||||
return any(c.get("name") == constraint_name for c in insp.get_check_constraints(table_name))
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
if not (
|
||||
_column_exists("api_keys", "is_standalone")
|
||||
and _column_exists("api_keys", "is_locked")
|
||||
and _column_exists("api_keys", "is_active")
|
||||
):
|
||||
return
|
||||
|
||||
op.execute(sa.text("""
|
||||
UPDATE api_keys
|
||||
SET is_active = FALSE,
|
||||
is_locked = FALSE
|
||||
WHERE is_standalone IS TRUE AND is_locked IS TRUE
|
||||
"""))
|
||||
|
||||
if not _check_constraint_exists("api_keys", _CONSTRAINT_NAME):
|
||||
op.create_check_constraint(
|
||||
_CONSTRAINT_NAME,
|
||||
"api_keys",
|
||||
"(NOT is_standalone) OR (NOT is_locked)",
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
if _check_constraint_exists("api_keys", _CONSTRAINT_NAME):
|
||||
op.drop_constraint(_CONSTRAINT_NAME, "api_keys", type_="check")
|
||||
@@ -0,0 +1,220 @@
|
||||
"""tighten wallet transaction snapshots and remove wallet version
|
||||
|
||||
Revision ID: 8e71f2a4c9b0
|
||||
Revises: 7c91d2e4f8a1
|
||||
Create Date: 2026-03-07 13:00:00.000000+00:00
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy import inspect
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "8e71f2a4c9b0"
|
||||
down_revision: str | None = "7c91d2e4f8a1"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
_WALLET_TX_BEFORE_CHECK = "ck_wallet_tx_balance_before_consistent"
|
||||
_WALLET_TX_AFTER_CHECK = "ck_wallet_tx_balance_after_consistent"
|
||||
_WALLET_LIMIT_MODE_INDEX = "idx_wallets_limit_mode"
|
||||
|
||||
|
||||
def _table_exists(table_name: str) -> bool:
|
||||
bind = op.get_bind()
|
||||
insp = inspect(bind)
|
||||
insp.clear_cache()
|
||||
return table_name in insp.get_table_names()
|
||||
|
||||
|
||||
def _column_exists(table_name: str, column_name: str) -> bool:
|
||||
bind = op.get_bind()
|
||||
insp = inspect(bind)
|
||||
insp.clear_cache()
|
||||
return column_name in [c["name"] for c in insp.get_columns(table_name)]
|
||||
|
||||
|
||||
def _index_exists(table_name: str, index_name: str) -> bool:
|
||||
bind = op.get_bind()
|
||||
insp = inspect(bind)
|
||||
insp.clear_cache()
|
||||
return any(index.get("name") == index_name for index in insp.get_indexes(table_name))
|
||||
|
||||
|
||||
def _check_constraint_exists(table_name: str, constraint_name: str) -> bool:
|
||||
bind = op.get_bind()
|
||||
insp = inspect(bind)
|
||||
insp.clear_cache()
|
||||
return any(c.get("name") == constraint_name for c in insp.get_check_constraints(table_name))
|
||||
|
||||
|
||||
def _tighten_wallet_transaction_snapshots() -> None:
|
||||
if not _table_exists("wallet_transactions"):
|
||||
return
|
||||
|
||||
required_columns = {
|
||||
"balance_before",
|
||||
"balance_after",
|
||||
"recharge_balance_before",
|
||||
"recharge_balance_after",
|
||||
"gift_balance_before",
|
||||
"gift_balance_after",
|
||||
}
|
||||
existing_columns = {
|
||||
column["name"] for column in inspect(op.get_bind()).get_columns("wallet_transactions")
|
||||
}
|
||||
if not required_columns.issubset(existing_columns):
|
||||
return
|
||||
|
||||
op.execute(
|
||||
sa.text(
|
||||
"""
|
||||
UPDATE wallet_transactions
|
||||
SET recharge_balance_before = balance_before
|
||||
WHERE recharge_balance_before IS NULL
|
||||
"""
|
||||
)
|
||||
)
|
||||
op.execute(
|
||||
sa.text(
|
||||
"""
|
||||
UPDATE wallet_transactions
|
||||
SET recharge_balance_after = balance_after
|
||||
WHERE recharge_balance_after IS NULL
|
||||
"""
|
||||
)
|
||||
)
|
||||
op.execute(
|
||||
sa.text(
|
||||
"""
|
||||
UPDATE wallet_transactions
|
||||
SET gift_balance_before = 0
|
||||
WHERE gift_balance_before IS NULL
|
||||
"""
|
||||
)
|
||||
)
|
||||
op.execute(
|
||||
sa.text(
|
||||
"""
|
||||
UPDATE wallet_transactions
|
||||
SET gift_balance_after = 0
|
||||
WHERE gift_balance_after IS NULL
|
||||
"""
|
||||
)
|
||||
)
|
||||
op.execute(
|
||||
sa.text(
|
||||
"""
|
||||
UPDATE wallet_transactions
|
||||
SET balance_before = recharge_balance_before + gift_balance_before,
|
||||
balance_after = recharge_balance_after + gift_balance_after
|
||||
"""
|
||||
)
|
||||
)
|
||||
|
||||
if not _check_constraint_exists("wallet_transactions", _WALLET_TX_BEFORE_CHECK):
|
||||
op.create_check_constraint(
|
||||
_WALLET_TX_BEFORE_CHECK,
|
||||
"wallet_transactions",
|
||||
"balance_before = recharge_balance_before + gift_balance_before",
|
||||
)
|
||||
if not _check_constraint_exists("wallet_transactions", _WALLET_TX_AFTER_CHECK):
|
||||
op.create_check_constraint(
|
||||
_WALLET_TX_AFTER_CHECK,
|
||||
"wallet_transactions",
|
||||
"balance_after = recharge_balance_after + gift_balance_after",
|
||||
)
|
||||
|
||||
op.alter_column(
|
||||
"wallet_transactions",
|
||||
"recharge_balance_before",
|
||||
existing_type=sa.Numeric(20, 8),
|
||||
nullable=False,
|
||||
)
|
||||
op.alter_column(
|
||||
"wallet_transactions",
|
||||
"recharge_balance_after",
|
||||
existing_type=sa.Numeric(20, 8),
|
||||
nullable=False,
|
||||
)
|
||||
op.alter_column(
|
||||
"wallet_transactions",
|
||||
"gift_balance_before",
|
||||
existing_type=sa.Numeric(20, 8),
|
||||
nullable=False,
|
||||
)
|
||||
op.alter_column(
|
||||
"wallet_transactions",
|
||||
"gift_balance_after",
|
||||
existing_type=sa.Numeric(20, 8),
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
|
||||
def _drop_wallet_cleanup_artifacts() -> None:
|
||||
if not _table_exists("wallets"):
|
||||
return
|
||||
|
||||
if _index_exists("wallets", _WALLET_LIMIT_MODE_INDEX):
|
||||
op.drop_index(_WALLET_LIMIT_MODE_INDEX, table_name="wallets")
|
||||
|
||||
if _column_exists("wallets", "version"):
|
||||
op.drop_column("wallets", "version")
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
_tighten_wallet_transaction_snapshots()
|
||||
_drop_wallet_cleanup_artifacts()
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
if _table_exists("wallets"):
|
||||
if not _column_exists("wallets", "version"):
|
||||
op.add_column(
|
||||
"wallets",
|
||||
sa.Column("version", sa.Integer(), nullable=False, server_default="0"),
|
||||
)
|
||||
if not _index_exists("wallets", _WALLET_LIMIT_MODE_INDEX):
|
||||
op.create_index(_WALLET_LIMIT_MODE_INDEX, "wallets", ["limit_mode"])
|
||||
|
||||
if not _table_exists("wallet_transactions"):
|
||||
return
|
||||
|
||||
if _column_exists("wallet_transactions", "recharge_balance_before"):
|
||||
op.alter_column(
|
||||
"wallet_transactions",
|
||||
"recharge_balance_before",
|
||||
existing_type=sa.Numeric(20, 8),
|
||||
nullable=True,
|
||||
)
|
||||
if _column_exists("wallet_transactions", "recharge_balance_after"):
|
||||
op.alter_column(
|
||||
"wallet_transactions",
|
||||
"recharge_balance_after",
|
||||
existing_type=sa.Numeric(20, 8),
|
||||
nullable=True,
|
||||
)
|
||||
if _column_exists("wallet_transactions", "gift_balance_before"):
|
||||
op.alter_column(
|
||||
"wallet_transactions",
|
||||
"gift_balance_before",
|
||||
existing_type=sa.Numeric(20, 8),
|
||||
nullable=True,
|
||||
)
|
||||
if _column_exists("wallet_transactions", "gift_balance_after"):
|
||||
op.alter_column(
|
||||
"wallet_transactions",
|
||||
"gift_balance_after",
|
||||
existing_type=sa.Numeric(20, 8),
|
||||
nullable=True,
|
||||
)
|
||||
|
||||
if _check_constraint_exists("wallet_transactions", _WALLET_TX_AFTER_CHECK):
|
||||
op.drop_constraint(_WALLET_TX_AFTER_CHECK, "wallet_transactions", type_="check")
|
||||
if _check_constraint_exists("wallet_transactions", _WALLET_TX_BEFORE_CHECK):
|
||||
op.drop_constraint(_WALLET_TX_BEFORE_CHECK, "wallet_transactions", type_="check")
|
||||
@@ -0,0 +1,80 @@
|
||||
"""add missing foreign key indexes for cascade delete performance
|
||||
|
||||
Revision ID: 2d932114930d
|
||||
Revises: 8e71f2a4c9b0
|
||||
Create Date: 2026-03-07 16:28:48.633531+00:00
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
from sqlalchemy import inspect
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '2d932114930d'
|
||||
down_revision = '8e71f2a4c9b0'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _index_exists(table_name: str, index_name: str) -> bool:
|
||||
bind = op.get_bind()
|
||||
insp = inspect(bind)
|
||||
return any(idx["name"] == index_name for idx in insp.get_indexes(table_name))
|
||||
|
||||
|
||||
def _create_index_if_not_exists(index_name: str, table_name: str, columns: list[str]) -> None:
|
||||
if not _index_exists(table_name, index_name):
|
||||
op.create_index(op.f(index_name), table_name, columns, unique=False)
|
||||
|
||||
|
||||
def _drop_index_if_exists(index_name: str, table_name: str) -> None:
|
||||
if _index_exists(table_name, index_name):
|
||||
op.drop_index(op.f(index_name), table_name=table_name)
|
||||
|
||||
|
||||
# (index_name, table_name, columns)
|
||||
_INDEXES = [
|
||||
# api_keys.user_id (CASCADE -> users.id)
|
||||
('ix_api_keys_user_id', 'api_keys', ['user_id']),
|
||||
# usage: wallet_id, provider_endpoint_id, provider_api_key_id (SET NULL)
|
||||
('ix_usage_wallet_id', 'usage', ['wallet_id']),
|
||||
('ix_usage_provider_endpoint_id', 'usage', ['provider_endpoint_id']),
|
||||
('ix_usage_provider_api_key_id', 'usage', ['provider_api_key_id']),
|
||||
# wallet_transactions.operator_id (SET NULL -> users.id)
|
||||
('ix_wallet_transactions_operator_id', 'wallet_transactions', ['operator_id']),
|
||||
# payment_callbacks.payment_order_id (SET NULL -> payment_orders.id)
|
||||
('ix_payment_callbacks_payment_order_id', 'payment_callbacks', ['payment_order_id']),
|
||||
# refund_requests: payment_order_id, requested_by, approved_by, processed_by (SET NULL)
|
||||
('ix_refund_requests_payment_order_id', 'refund_requests', ['payment_order_id']),
|
||||
('ix_refund_requests_requested_by', 'refund_requests', ['requested_by']),
|
||||
('ix_refund_requests_approved_by', 'refund_requests', ['approved_by']),
|
||||
('ix_refund_requests_processed_by', 'refund_requests', ['processed_by']),
|
||||
# proxy_nodes.registered_by (SET NULL -> users.id)
|
||||
('ix_proxy_nodes_registered_by', 'proxy_nodes', ['registered_by']),
|
||||
# video_tasks: api_key_id, provider_id, endpoint_id, key_id, remixed_from_task_id
|
||||
('ix_video_tasks_api_key_id', 'video_tasks', ['api_key_id']),
|
||||
('ix_video_tasks_provider_id', 'video_tasks', ['provider_id']),
|
||||
('ix_video_tasks_endpoint_id', 'video_tasks', ['endpoint_id']),
|
||||
('ix_video_tasks_key_id', 'video_tasks', ['key_id']),
|
||||
('ix_video_tasks_remixed_from_task_id', 'video_tasks', ['remixed_from_task_id']),
|
||||
# user_preferences.default_provider_id (-> providers.id)
|
||||
('ix_user_preferences_default_provider_id', 'user_preferences', ['default_provider_id']),
|
||||
# announcements.author_id (SET NULL -> users.id)
|
||||
('ix_announcements_author_id', 'announcements', ['author_id']),
|
||||
# announcement_reads.announcement_id (-> announcements.id)
|
||||
('ix_announcement_reads_announcement_id', 'announcement_reads', ['announcement_id']),
|
||||
# request_candidates: user_id, api_key_id, endpoint_id, key_id (CASCADE)
|
||||
('ix_request_candidates_user_id', 'request_candidates', ['user_id']),
|
||||
('ix_request_candidates_api_key_id', 'request_candidates', ['api_key_id']),
|
||||
('ix_request_candidates_endpoint_id', 'request_candidates', ['endpoint_id']),
|
||||
('ix_request_candidates_key_id', 'request_candidates', ['key_id']),
|
||||
]
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
for index_name, table_name, columns in _INDEXES:
|
||||
_create_index_if_not_exists(index_name, table_name, columns)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
for index_name, table_name, _columns in reversed(_INDEXES):
|
||||
_drop_index_if_exists(index_name, table_name)
|
||||
@@ -0,0 +1,224 @@
|
||||
"""usage stats retention: SET NULL on delete and add name snapshots
|
||||
|
||||
Revision ID: 45b118150a78
|
||||
Revises: 2d932114930d
|
||||
Create Date: 2026-03-08 03:48:49.622091+00:00
|
||||
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "45b118150a78"
|
||||
down_revision = "2d932114930d"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
_TABLES = ["usage", "stats_user_daily", "stats_daily_api_key"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Inline helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _SchemaCache:
|
||||
def __init__(self) -> None:
|
||||
self._columns: dict[str, dict[str, str]] = {}
|
||||
self._fk_rules: dict[tuple[str, str], str] = {}
|
||||
self._fk_loaded_tables: set[str] = set()
|
||||
|
||||
def load_columns(self, tables: list[str]) -> None:
|
||||
need = [t for t in tables if t not in self._columns]
|
||||
if not need:
|
||||
return
|
||||
bind = op.get_bind()
|
||||
rows = bind.execute(
|
||||
sa.text(
|
||||
"SELECT table_name, column_name, data_type "
|
||||
"FROM information_schema.columns "
|
||||
"WHERE table_name = ANY(:tables) "
|
||||
" AND table_schema = current_schema()"
|
||||
),
|
||||
{"tables": need},
|
||||
).fetchall()
|
||||
for t in need:
|
||||
self._columns.setdefault(t, {})
|
||||
for table, col, dtype in rows:
|
||||
self._columns[table][col] = dtype
|
||||
|
||||
def load_fk_rules(self, tables: list[str]) -> None:
|
||||
need = [t for t in tables if t not in self._fk_loaded_tables]
|
||||
if not need:
|
||||
return
|
||||
bind = op.get_bind()
|
||||
rows = bind.execute(
|
||||
sa.text(
|
||||
"SELECT tc.table_name, tc.constraint_name, rc.delete_rule "
|
||||
"FROM information_schema.referential_constraints rc "
|
||||
"JOIN information_schema.table_constraints tc "
|
||||
" ON rc.constraint_name = tc.constraint_name "
|
||||
" AND rc.constraint_schema = tc.constraint_schema "
|
||||
"WHERE tc.table_name = ANY(:tables) "
|
||||
" AND tc.table_schema = current_schema()"
|
||||
),
|
||||
{"tables": need},
|
||||
).fetchall()
|
||||
for table, name, rule in rows:
|
||||
self._fk_rules[(table, name)] = rule
|
||||
self._fk_loaded_tables.update(need)
|
||||
|
||||
def column_exists(self, table: str, column: str) -> bool:
|
||||
return column in self._columns.get(table, {})
|
||||
|
||||
def fk_ondelete(self, table: str, constraint: str) -> str | None:
|
||||
return self._fk_rules.get((table, constraint))
|
||||
|
||||
|
||||
def _fk_exists(constraint_name: str, table_name: str) -> bool:
|
||||
bind = op.get_bind()
|
||||
result = bind.execute(
|
||||
sa.text(
|
||||
"SELECT 1 FROM pg_constraint c "
|
||||
"JOIN pg_class r ON c.conrelid = r.oid "
|
||||
"JOIN pg_namespace n ON r.relnamespace = n.oid "
|
||||
"WHERE c.conname = :name AND r.relname = :table "
|
||||
" AND n.nspname = current_schema() AND c.contype = 'f'"
|
||||
),
|
||||
{"name": constraint_name, "table": table_name},
|
||||
)
|
||||
return result.scalar() is not None
|
||||
|
||||
|
||||
def _replace_fk_if_needed(
|
||||
cache: _SchemaCache,
|
||||
constraint_name: str,
|
||||
table_name: str,
|
||||
ref_table: str,
|
||||
local_cols: list[str],
|
||||
remote_cols: list[str],
|
||||
desired_ondelete: str,
|
||||
) -> None:
|
||||
current = cache.fk_ondelete(table_name, constraint_name)
|
||||
if current and current.upper() == desired_ondelete.upper():
|
||||
return
|
||||
if current or _fk_exists(constraint_name, table_name):
|
||||
op.drop_constraint(constraint_name, table_name, type_="foreignkey")
|
||||
op.create_foreign_key(
|
||||
constraint_name,
|
||||
table_name,
|
||||
ref_table,
|
||||
local_cols,
|
||||
remote_cols,
|
||||
ondelete=desired_ondelete,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
c = _SchemaCache()
|
||||
c.load_columns(_TABLES)
|
||||
c.load_fk_rules(["stats_user_daily", "stats_daily_api_key"])
|
||||
|
||||
# --- Usage: add name snapshot columns ---
|
||||
if not c.column_exists("usage", "username"):
|
||||
op.add_column(
|
||||
"usage", sa.Column("username", sa.String(100), nullable=True, comment="用户名快照")
|
||||
)
|
||||
if not c.column_exists("usage", "api_key_name"):
|
||||
op.add_column(
|
||||
"usage",
|
||||
sa.Column("api_key_name", sa.String(200), nullable=True, comment="API Key 名称快照"),
|
||||
)
|
||||
|
||||
# --- StatsUserDaily: CASCADE -> SET NULL, add username snapshot ---
|
||||
_replace_fk_if_needed(
|
||||
c,
|
||||
"stats_user_daily_user_id_fkey",
|
||||
"stats_user_daily",
|
||||
"users",
|
||||
["user_id"],
|
||||
["id"],
|
||||
"SET NULL",
|
||||
)
|
||||
op.alter_column("stats_user_daily", "user_id", existing_type=sa.String(36), nullable=True)
|
||||
if not c.column_exists("stats_user_daily", "username"):
|
||||
op.add_column(
|
||||
"stats_user_daily",
|
||||
sa.Column(
|
||||
"username",
|
||||
sa.String(100),
|
||||
nullable=True,
|
||||
comment="用户名快照(删除用户后仍可追溯)",
|
||||
),
|
||||
)
|
||||
|
||||
# --- StatsDailyApiKey: CASCADE -> SET NULL, add api_key_name snapshot ---
|
||||
_replace_fk_if_needed(
|
||||
c,
|
||||
"stats_daily_api_key_api_key_id_fkey",
|
||||
"stats_daily_api_key",
|
||||
"api_keys",
|
||||
["api_key_id"],
|
||||
["id"],
|
||||
"SET NULL",
|
||||
)
|
||||
op.alter_column("stats_daily_api_key", "api_key_id", existing_type=sa.String(36), nullable=True)
|
||||
if not c.column_exists("stats_daily_api_key", "api_key_name"):
|
||||
op.add_column(
|
||||
"stats_daily_api_key",
|
||||
sa.Column(
|
||||
"api_key_name",
|
||||
sa.String(200),
|
||||
nullable=True,
|
||||
comment="API Key 名称快照(删除 Key 后仍可追溯)",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
c = _SchemaCache()
|
||||
c.load_columns(["stats_daily_api_key", "stats_user_daily", "usage"])
|
||||
c.load_fk_rules(["stats_daily_api_key", "stats_user_daily"])
|
||||
|
||||
# --- Remove snapshot columns ---
|
||||
if c.column_exists("stats_daily_api_key", "api_key_name"):
|
||||
op.drop_column("stats_daily_api_key", "api_key_name")
|
||||
if c.column_exists("stats_user_daily", "username"):
|
||||
op.drop_column("stats_user_daily", "username")
|
||||
if c.column_exists("usage", "api_key_name"):
|
||||
op.drop_column("usage", "api_key_name")
|
||||
if c.column_exists("usage", "username"):
|
||||
op.drop_column("usage", "username")
|
||||
|
||||
# --- StatsDailyApiKey: SET NULL -> CASCADE ---
|
||||
_replace_fk_if_needed(
|
||||
c,
|
||||
"stats_daily_api_key_api_key_id_fkey",
|
||||
"stats_daily_api_key",
|
||||
"api_keys",
|
||||
["api_key_id"],
|
||||
["id"],
|
||||
"CASCADE",
|
||||
)
|
||||
op.alter_column(
|
||||
"stats_daily_api_key", "api_key_id", existing_type=sa.String(36), nullable=False
|
||||
)
|
||||
|
||||
# --- StatsUserDaily: SET NULL -> CASCADE ---
|
||||
_replace_fk_if_needed(
|
||||
c,
|
||||
"stats_user_daily_user_id_fkey",
|
||||
"stats_user_daily",
|
||||
"users",
|
||||
["user_id"],
|
||||
["id"],
|
||||
"CASCADE",
|
||||
)
|
||||
op.alter_column("stats_user_daily", "user_id", existing_type=sa.String(36), nullable=False)
|
||||
@@ -0,0 +1,257 @@
|
||||
"""request_candidates/video_tasks retention: SET NULL and add snapshots
|
||||
|
||||
Revision ID: 13a4c8f6d9e0
|
||||
Revises: 45b118150a78
|
||||
Create Date: 2026-03-08 12:15:00.000000+00:00
|
||||
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "13a4c8f6d9e0"
|
||||
down_revision = "45b118150a78"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
_TABLES = ["request_candidates", "video_tasks"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Inline helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _SchemaCache:
|
||||
def __init__(self) -> None:
|
||||
self._columns: dict[str, dict[str, str]] = {}
|
||||
self._fk_rules: dict[tuple[str, str], str] = {}
|
||||
self._fk_loaded_tables: set[str] = set()
|
||||
|
||||
def load_columns(self, tables: list[str]) -> None:
|
||||
need = [t for t in tables if t not in self._columns]
|
||||
if not need:
|
||||
return
|
||||
bind = op.get_bind()
|
||||
rows = bind.execute(
|
||||
sa.text(
|
||||
"SELECT table_name, column_name, data_type "
|
||||
"FROM information_schema.columns "
|
||||
"WHERE table_name = ANY(:tables) "
|
||||
" AND table_schema = current_schema()"
|
||||
),
|
||||
{"tables": need},
|
||||
).fetchall()
|
||||
for t in need:
|
||||
self._columns.setdefault(t, {})
|
||||
for table, col, dtype in rows:
|
||||
self._columns[table][col] = dtype
|
||||
|
||||
def load_fk_rules(self, tables: list[str]) -> None:
|
||||
need = [t for t in tables if t not in self._fk_loaded_tables]
|
||||
if not need:
|
||||
return
|
||||
bind = op.get_bind()
|
||||
rows = bind.execute(
|
||||
sa.text(
|
||||
"SELECT tc.table_name, tc.constraint_name, rc.delete_rule "
|
||||
"FROM information_schema.referential_constraints rc "
|
||||
"JOIN information_schema.table_constraints tc "
|
||||
" ON rc.constraint_name = tc.constraint_name "
|
||||
" AND rc.constraint_schema = tc.constraint_schema "
|
||||
"WHERE tc.table_name = ANY(:tables) "
|
||||
" AND tc.table_schema = current_schema()"
|
||||
),
|
||||
{"tables": need},
|
||||
).fetchall()
|
||||
for table, name, rule in rows:
|
||||
self._fk_rules[(table, name)] = rule
|
||||
self._fk_loaded_tables.update(need)
|
||||
|
||||
def column_exists(self, table: str, column: str) -> bool:
|
||||
return column in self._columns.get(table, {})
|
||||
|
||||
def fk_ondelete(self, table: str, constraint: str) -> str | None:
|
||||
return self._fk_rules.get((table, constraint))
|
||||
|
||||
|
||||
def _fk_exists(constraint_name: str, table_name: str) -> bool:
|
||||
bind = op.get_bind()
|
||||
result = bind.execute(
|
||||
sa.text(
|
||||
"SELECT 1 FROM pg_constraint c "
|
||||
"JOIN pg_class r ON c.conrelid = r.oid "
|
||||
"JOIN pg_namespace n ON r.relnamespace = n.oid "
|
||||
"WHERE c.conname = :name AND r.relname = :table "
|
||||
" AND n.nspname = current_schema() AND c.contype = 'f'"
|
||||
),
|
||||
{"name": constraint_name, "table": table_name},
|
||||
)
|
||||
return result.scalar() is not None
|
||||
|
||||
|
||||
def _replace_fk_if_needed(
|
||||
cache: _SchemaCache,
|
||||
constraint_name: str,
|
||||
table_name: str,
|
||||
ref_table: str,
|
||||
local_cols: list[str],
|
||||
remote_cols: list[str],
|
||||
desired_ondelete: str,
|
||||
) -> None:
|
||||
current = cache.fk_ondelete(table_name, constraint_name)
|
||||
if current and current.upper() == desired_ondelete.upper():
|
||||
return
|
||||
if current or _fk_exists(constraint_name, table_name):
|
||||
op.drop_constraint(constraint_name, table_name, type_="foreignkey")
|
||||
op.create_foreign_key(
|
||||
constraint_name,
|
||||
table_name,
|
||||
ref_table,
|
||||
local_cols,
|
||||
remote_cols,
|
||||
ondelete=desired_ondelete,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
c = _SchemaCache()
|
||||
c.load_columns(_TABLES)
|
||||
c.load_fk_rules(_TABLES)
|
||||
|
||||
# --- request_candidates: add snapshot columns ---
|
||||
if not c.column_exists("request_candidates", "username"):
|
||||
op.add_column(
|
||||
"request_candidates",
|
||||
sa.Column("username", sa.String(length=100), nullable=True, comment="用户名快照"),
|
||||
)
|
||||
if not c.column_exists("request_candidates", "api_key_name"):
|
||||
op.add_column(
|
||||
"request_candidates",
|
||||
sa.Column(
|
||||
"api_key_name",
|
||||
sa.String(length=200),
|
||||
nullable=True,
|
||||
comment="API Key 名称快照",
|
||||
),
|
||||
)
|
||||
|
||||
# --- request_candidates: CASCADE -> SET NULL ---
|
||||
_replace_fk_if_needed(
|
||||
c,
|
||||
"request_candidates_user_id_fkey",
|
||||
"request_candidates",
|
||||
"users",
|
||||
["user_id"],
|
||||
["id"],
|
||||
"SET NULL",
|
||||
)
|
||||
_replace_fk_if_needed(
|
||||
c,
|
||||
"request_candidates_api_key_id_fkey",
|
||||
"request_candidates",
|
||||
"api_keys",
|
||||
["api_key_id"],
|
||||
["id"],
|
||||
"SET NULL",
|
||||
)
|
||||
|
||||
# --- video_tasks: add snapshot columns ---
|
||||
if not c.column_exists("video_tasks", "username"):
|
||||
op.add_column(
|
||||
"video_tasks",
|
||||
sa.Column("username", sa.String(length=100), nullable=True, comment="用户名快照"),
|
||||
)
|
||||
if not c.column_exists("video_tasks", "api_key_name"):
|
||||
op.add_column(
|
||||
"video_tasks",
|
||||
sa.Column(
|
||||
"api_key_name",
|
||||
sa.String(length=200),
|
||||
nullable=True,
|
||||
comment="API Key 名称快照",
|
||||
),
|
||||
)
|
||||
|
||||
# --- video_tasks: CASCADE -> SET NULL, user_id nullable ---
|
||||
op.alter_column("video_tasks", "user_id", existing_type=sa.String(length=36), nullable=True)
|
||||
_replace_fk_if_needed(
|
||||
c,
|
||||
"video_tasks_user_id_fkey",
|
||||
"video_tasks",
|
||||
"users",
|
||||
["user_id"],
|
||||
["id"],
|
||||
"SET NULL",
|
||||
)
|
||||
_replace_fk_if_needed(
|
||||
c,
|
||||
"video_tasks_api_key_id_fkey",
|
||||
"video_tasks",
|
||||
"api_keys",
|
||||
["api_key_id"],
|
||||
["id"],
|
||||
"SET NULL",
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
c = _SchemaCache()
|
||||
c.load_columns(_TABLES)
|
||||
c.load_fk_rules(_TABLES)
|
||||
|
||||
# --- video_tasks: SET NULL -> default (no action), restore NOT NULL ---
|
||||
_replace_fk_if_needed(
|
||||
c,
|
||||
"video_tasks_api_key_id_fkey",
|
||||
"video_tasks",
|
||||
"api_keys",
|
||||
["api_key_id"],
|
||||
["id"],
|
||||
"NO ACTION",
|
||||
)
|
||||
_replace_fk_if_needed(
|
||||
c,
|
||||
"video_tasks_user_id_fkey",
|
||||
"video_tasks",
|
||||
"users",
|
||||
["user_id"],
|
||||
["id"],
|
||||
"NO ACTION",
|
||||
)
|
||||
op.alter_column("video_tasks", "user_id", existing_type=sa.String(length=36), nullable=False)
|
||||
if c.column_exists("video_tasks", "api_key_name"):
|
||||
op.drop_column("video_tasks", "api_key_name")
|
||||
if c.column_exists("video_tasks", "username"):
|
||||
op.drop_column("video_tasks", "username")
|
||||
|
||||
# --- request_candidates: SET NULL -> CASCADE ---
|
||||
_replace_fk_if_needed(
|
||||
c,
|
||||
"request_candidates_api_key_id_fkey",
|
||||
"request_candidates",
|
||||
"api_keys",
|
||||
["api_key_id"],
|
||||
["id"],
|
||||
"CASCADE",
|
||||
)
|
||||
_replace_fk_if_needed(
|
||||
c,
|
||||
"request_candidates_user_id_fkey",
|
||||
"request_candidates",
|
||||
"users",
|
||||
["user_id"],
|
||||
["id"],
|
||||
"CASCADE",
|
||||
)
|
||||
if c.column_exists("request_candidates", "api_key_name"):
|
||||
op.drop_column("request_candidates", "api_key_name")
|
||||
if c.column_exists("request_candidates", "username"):
|
||||
op.drop_column("request_candidates", "username")
|
||||
@@ -0,0 +1,230 @@
|
||||
"""cost fields: Float -> Numeric(20,8) + provider_api_keys composite index
|
||||
|
||||
Revision ID: 2053ab8ed764
|
||||
Revises: 13a4c8f6d9e0
|
||||
Create Date: 2026-03-08 15:30:00.000000+00:00
|
||||
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from collections import defaultdict
|
||||
from collections.abc import Callable
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "2053ab8ed764"
|
||||
down_revision = "13a4c8f6d9e0"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
# (table_name, column_name, nullable, server_default)
|
||||
_COST_COLUMNS: list[tuple[str, str, bool, str | None]] = [
|
||||
# api_keys
|
||||
("api_keys", "total_cost_usd", True, "0.0"),
|
||||
# usage
|
||||
("usage", "input_cost_usd", True, "0.0"),
|
||||
("usage", "output_cost_usd", True, "0.0"),
|
||||
("usage", "cache_cost_usd", True, "0.0"),
|
||||
("usage", "cache_creation_cost_usd", True, "0.0"),
|
||||
("usage", "cache_read_cost_usd", True, "0.0"),
|
||||
("usage", "request_cost_usd", True, "0.0"),
|
||||
("usage", "total_cost_usd", True, "0.0"),
|
||||
("usage", "actual_input_cost_usd", True, "0.0"),
|
||||
("usage", "actual_output_cost_usd", True, "0.0"),
|
||||
("usage", "actual_cache_creation_cost_usd", True, "0.0"),
|
||||
("usage", "actual_cache_read_cost_usd", True, "0.0"),
|
||||
("usage", "actual_request_cost_usd", True, "0.0"),
|
||||
("usage", "actual_total_cost_usd", True, "0.0"),
|
||||
("usage", "rate_multiplier", True, "1.0"),
|
||||
("usage", "input_price_per_1m", True, None),
|
||||
("usage", "output_price_per_1m", True, None),
|
||||
("usage", "cache_creation_price_per_1m", True, None),
|
||||
("usage", "cache_read_price_per_1m", True, None),
|
||||
("usage", "price_per_request", True, None),
|
||||
# providers
|
||||
("providers", "monthly_quota_usd", True, None),
|
||||
("providers", "monthly_used_usd", True, "0.0"),
|
||||
# global_models
|
||||
("global_models", "default_price_per_request", True, None),
|
||||
# models
|
||||
("models", "price_per_request", True, None),
|
||||
# stats_hourly
|
||||
("stats_hourly", "total_cost", False, "0.0"),
|
||||
("stats_hourly", "actual_total_cost", False, "0.0"),
|
||||
# stats_hourly_user
|
||||
("stats_hourly_user", "total_cost", False, "0.0"),
|
||||
# stats_hourly_model
|
||||
("stats_hourly_model", "total_cost", False, "0.0"),
|
||||
# stats_hourly_provider
|
||||
("stats_hourly_provider", "total_cost", False, "0.0"),
|
||||
# stats_daily
|
||||
("stats_daily", "total_cost", False, "0.0"),
|
||||
("stats_daily", "actual_total_cost", False, "0.0"),
|
||||
("stats_daily", "input_cost", False, "0.0"),
|
||||
("stats_daily", "output_cost", False, "0.0"),
|
||||
("stats_daily", "cache_creation_cost", False, "0.0"),
|
||||
("stats_daily", "cache_read_cost", False, "0.0"),
|
||||
# stats_daily_model
|
||||
("stats_daily_model", "total_cost", False, "0.0"),
|
||||
# stats_daily_provider
|
||||
("stats_daily_provider", "total_cost", False, "0.0"),
|
||||
# stats_daily_api_key
|
||||
("stats_daily_api_key", "total_cost", False, "0.0"),
|
||||
# stats_summary
|
||||
("stats_summary", "all_time_cost", False, "0.0"),
|
||||
("stats_summary", "all_time_actual_cost", False, "0.0"),
|
||||
# stats_user_daily
|
||||
("stats_user_daily", "total_cost", False, "0.0"),
|
||||
]
|
||||
|
||||
_ALL_TABLES = list({t for t, *_ in _COST_COLUMNS})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Inline helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _SchemaCache:
|
||||
def __init__(self) -> None:
|
||||
self._columns: dict[str, dict[str, str]] = {}
|
||||
|
||||
def load_columns(self, tables: list[str]) -> None:
|
||||
need = [t for t in tables if t not in self._columns]
|
||||
if not need:
|
||||
return
|
||||
bind = op.get_bind()
|
||||
rows = bind.execute(
|
||||
sa.text(
|
||||
"SELECT table_name, column_name, data_type "
|
||||
"FROM information_schema.columns "
|
||||
"WHERE table_name = ANY(:tables) "
|
||||
" AND table_schema = current_schema()"
|
||||
),
|
||||
{"tables": need},
|
||||
).fetchall()
|
||||
for t in need:
|
||||
self._columns.setdefault(t, {})
|
||||
for table, col, dtype in rows:
|
||||
self._columns[table][col] = dtype
|
||||
|
||||
def column_exists(self, table: str, column: str) -> bool:
|
||||
return column in self._columns.get(table, {})
|
||||
|
||||
def column_type(self, table: str, column: str) -> str | None:
|
||||
return self._columns.get(table, {}).get(column)
|
||||
|
||||
def is_numeric(self, table: str, column: str) -> bool:
|
||||
return self.column_type(table, column) == "numeric"
|
||||
|
||||
|
||||
def _index_exists(index_name: str) -> bool:
|
||||
bind = op.get_bind()
|
||||
result = bind.execute(
|
||||
sa.text(
|
||||
"SELECT 1 FROM pg_indexes "
|
||||
"WHERE indexname = :name AND schemaname = current_schema()::text"
|
||||
),
|
||||
{"name": index_name},
|
||||
)
|
||||
return result.scalar() is not None
|
||||
|
||||
|
||||
def _numeric_max(type_spec: str) -> float | None:
|
||||
m = re.match(r"NUMERIC\((\d+),(\d+)\)", type_spec, re.IGNORECASE)
|
||||
if not m:
|
||||
return None
|
||||
precision, scale = int(m.group(1)), int(m.group(2))
|
||||
return 10 ** (precision - scale) - 10 ** (-scale)
|
||||
|
||||
|
||||
def _batch_alter_type(
|
||||
cache: _SchemaCache,
|
||||
columns: list[tuple[str, str, bool, str | None]],
|
||||
cast_suffix: str,
|
||||
type_fn: Callable[[str], str],
|
||||
) -> None:
|
||||
by_table: dict[str, list[tuple[str, str]]] = defaultdict(list)
|
||||
for table, col, _nullable, _default in columns:
|
||||
if not cache.column_exists(table, col):
|
||||
continue
|
||||
by_table[table].append((col, type_fn(col)))
|
||||
|
||||
bind = op.get_bind()
|
||||
for table, col_types in by_table.items():
|
||||
for col, target in col_types:
|
||||
cap = _numeric_max(target)
|
||||
if cap is not None:
|
||||
bind.execute(
|
||||
sa.text(
|
||||
f"UPDATE {table} SET {col} = :cap "
|
||||
f"WHERE {col} IS NOT NULL AND abs({col}) > :cap"
|
||||
),
|
||||
{"cap": cap},
|
||||
)
|
||||
parts = [
|
||||
f"ALTER COLUMN {col} TYPE {target} USING {col}::{cast_suffix}"
|
||||
for col, target in col_types
|
||||
]
|
||||
if parts:
|
||||
bind.execute(sa.text(f"ALTER TABLE {table} " + ", ".join(parts)))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _type_spec(col: str) -> str:
|
||||
"""Return the SQL type literal for a given column name."""
|
||||
return "NUMERIC(10,6)" if col == "rate_multiplier" else "NUMERIC(20,8)"
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
c = _SchemaCache()
|
||||
c.load_columns(_ALL_TABLES)
|
||||
|
||||
# -- 1. cost fields: Float -> Numeric (batched per table)
|
||||
cols_to_convert = [
|
||||
(t, col, n, d)
|
||||
for t, col, n, d in _COST_COLUMNS
|
||||
if c.column_exists(t, col) and not c.is_numeric(t, col)
|
||||
]
|
||||
_batch_alter_type(c, cols_to_convert, cast_suffix="numeric", type_fn=_type_spec)
|
||||
|
||||
# -- 2. provider_api_keys composite index
|
||||
if not _index_exists("idx_provider_api_keys_provider_active"):
|
||||
op.create_index(
|
||||
"idx_provider_api_keys_provider_active",
|
||||
"provider_api_keys",
|
||||
["provider_id", "is_active"],
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# -- 2. drop composite index
|
||||
if _index_exists("idx_provider_api_keys_provider_active"):
|
||||
op.drop_index(
|
||||
"idx_provider_api_keys_provider_active",
|
||||
table_name="provider_api_keys",
|
||||
)
|
||||
|
||||
# -- 1. Numeric -> Float (batched per table)
|
||||
c = _SchemaCache()
|
||||
c.load_columns(_ALL_TABLES)
|
||||
|
||||
cols_to_revert = [
|
||||
(t, col, n, d)
|
||||
for t, col, n, d in _COST_COLUMNS
|
||||
if c.column_exists(t, col) and c.is_numeric(t, col)
|
||||
]
|
||||
_batch_alter_type(
|
||||
c,
|
||||
cols_to_revert,
|
||||
cast_suffix="double precision",
|
||||
type_fn=lambda _col: "DOUBLE PRECISION",
|
||||
)
|
||||
@@ -0,0 +1,127 @@
|
||||
"""video_tasks.key_id: add ondelete SET NULL
|
||||
|
||||
Revision ID: d7649c1f8e21
|
||||
Revises: 2053ab8ed764
|
||||
Create Date: 2026-03-09 01:00:00.000000+00:00
|
||||
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "d7649c1f8e21"
|
||||
down_revision = "2053ab8ed764"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
_TABLE = "video_tasks"
|
||||
_FK_NAME = "video_tasks_key_id_fkey"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Inline helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _SchemaCache:
|
||||
def __init__(self) -> None:
|
||||
self._fk_rules: dict[tuple[str, str], str] = {}
|
||||
self._fk_loaded_tables: set[str] = set()
|
||||
|
||||
def load_fk_rules(self, tables: list[str]) -> None:
|
||||
need = [t for t in tables if t not in self._fk_loaded_tables]
|
||||
if not need:
|
||||
return
|
||||
bind = op.get_bind()
|
||||
rows = bind.execute(
|
||||
sa.text(
|
||||
"SELECT tc.table_name, tc.constraint_name, rc.delete_rule "
|
||||
"FROM information_schema.referential_constraints rc "
|
||||
"JOIN information_schema.table_constraints tc "
|
||||
" ON rc.constraint_name = tc.constraint_name "
|
||||
" AND rc.constraint_schema = tc.constraint_schema "
|
||||
"WHERE tc.table_name = ANY(:tables) "
|
||||
" AND tc.table_schema = current_schema()"
|
||||
),
|
||||
{"tables": need},
|
||||
).fetchall()
|
||||
for table, name, rule in rows:
|
||||
self._fk_rules[(table, name)] = rule
|
||||
self._fk_loaded_tables.update(need)
|
||||
|
||||
def fk_ondelete(self, table: str, constraint: str) -> str | None:
|
||||
return self._fk_rules.get((table, constraint))
|
||||
|
||||
|
||||
def _fk_exists(constraint_name: str, table_name: str) -> bool:
|
||||
bind = op.get_bind()
|
||||
result = bind.execute(
|
||||
sa.text(
|
||||
"SELECT 1 FROM pg_constraint c "
|
||||
"JOIN pg_class r ON c.conrelid = r.oid "
|
||||
"JOIN pg_namespace n ON r.relnamespace = n.oid "
|
||||
"WHERE c.conname = :name AND r.relname = :table "
|
||||
" AND n.nspname = current_schema() AND c.contype = 'f'"
|
||||
),
|
||||
{"name": constraint_name, "table": table_name},
|
||||
)
|
||||
return result.scalar() is not None
|
||||
|
||||
|
||||
def _replace_fk_if_needed(
|
||||
cache: _SchemaCache,
|
||||
constraint_name: str,
|
||||
table_name: str,
|
||||
ref_table: str,
|
||||
local_cols: list[str],
|
||||
remote_cols: list[str],
|
||||
desired_ondelete: str,
|
||||
) -> None:
|
||||
current = cache.fk_ondelete(table_name, constraint_name)
|
||||
if current and current.upper() == desired_ondelete.upper():
|
||||
return
|
||||
if current or _fk_exists(constraint_name, table_name):
|
||||
op.drop_constraint(constraint_name, table_name, type_="foreignkey")
|
||||
op.create_foreign_key(
|
||||
constraint_name,
|
||||
table_name,
|
||||
ref_table,
|
||||
local_cols,
|
||||
remote_cols,
|
||||
ondelete=desired_ondelete,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
c = _SchemaCache()
|
||||
c.load_fk_rules([_TABLE])
|
||||
_replace_fk_if_needed(
|
||||
c,
|
||||
_FK_NAME,
|
||||
_TABLE,
|
||||
"provider_api_keys",
|
||||
["key_id"],
|
||||
["id"],
|
||||
"SET NULL",
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
c = _SchemaCache()
|
||||
c.load_fk_rules([_TABLE])
|
||||
_replace_fk_if_needed(
|
||||
c,
|
||||
_FK_NAME,
|
||||
_TABLE,
|
||||
"provider_api_keys",
|
||||
["key_id"],
|
||||
["id"],
|
||||
"NO ACTION",
|
||||
)
|
||||
@@ -0,0 +1,42 @@
|
||||
"""Strip request_results_window from health_by_format JSON.
|
||||
|
||||
This data is now maintained in process memory only, no longer persisted to DB.
|
||||
|
||||
Revision ID: a3f1b7c9d2e4
|
||||
Revises: d7649c1f8e21
|
||||
Create Date: 2026-03-10 12:00:00.000000+00:00
|
||||
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "a3f1b7c9d2e4"
|
||||
down_revision = "d7649c1f8e21"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute("""
|
||||
UPDATE provider_api_keys
|
||||
SET health_by_format = (
|
||||
SELECT jsonb_object_agg(
|
||||
fmt_key,
|
||||
fmt_value - 'request_results_window'
|
||||
)
|
||||
FROM jsonb_each(health_by_format) AS x(fmt_key, fmt_value)
|
||||
)
|
||||
WHERE health_by_format IS NOT NULL
|
||||
AND health_by_format != '{}'::jsonb
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM jsonb_each(health_by_format) AS x(fmt_key, fmt_value)
|
||||
WHERE fmt_value ? 'request_results_window'
|
||||
)
|
||||
""")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# No-op: window data is rebuilt from scratch on process start
|
||||
pass
|
||||
@@ -0,0 +1,159 @@
|
||||
"""tighten usage billing state machine
|
||||
|
||||
Revision ID: 9e4f1a2b3c4d
|
||||
Revises: a3f1b7c9d2e4
|
||||
Create Date: 2026-03-11 19:00:00.000000+00:00
|
||||
|
||||
This migration does two things:
|
||||
1. Change new `usage.billing_status` default from `settled` to `pending`.
|
||||
2. Repair only the clearly-safe inconsistent historical rows for production:
|
||||
- failed/cancelled zero-cost rows that were marked settled are converted to void
|
||||
- terminal rows missing finalized_at are backfilled from created_at
|
||||
|
||||
Ambiguous positive-cost settled rows are intentionally left untouched for manual audit.
|
||||
|
||||
All data updates are batched (10000 rows per iteration) to avoid long-held locks
|
||||
and excessive WAL generation on large usage tables.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy import inspect
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "9e4f1a2b3c4d"
|
||||
down_revision: str | None = "a3f1b7c9d2e4"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
BATCH_SIZE = 10000
|
||||
|
||||
|
||||
def _table_exists(table_name: str) -> bool:
|
||||
bind = op.get_bind()
|
||||
insp = inspect(bind)
|
||||
insp.clear_cache()
|
||||
return table_name in insp.get_table_names()
|
||||
|
||||
|
||||
def _column_exists(table_name: str, column_name: str) -> bool:
|
||||
bind = op.get_bind()
|
||||
insp = inspect(bind)
|
||||
insp.clear_cache()
|
||||
return column_name in [col["name"] for col in insp.get_columns(table_name)]
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
if not _table_exists("usage"):
|
||||
return
|
||||
|
||||
if _column_exists("usage", "billing_status"):
|
||||
op.alter_column(
|
||||
"usage",
|
||||
"billing_status",
|
||||
existing_type=sa.String(length=20),
|
||||
server_default="pending",
|
||||
existing_nullable=False,
|
||||
)
|
||||
|
||||
required_columns = {
|
||||
"billing_status",
|
||||
"status",
|
||||
"total_cost_usd",
|
||||
"request_cost_usd",
|
||||
"actual_total_cost_usd",
|
||||
"actual_request_cost_usd",
|
||||
"wallet_balance_after",
|
||||
"finalized_at",
|
||||
"created_at",
|
||||
}
|
||||
if not required_columns.issubset(
|
||||
{col for col in required_columns if _column_exists("usage", col)}
|
||||
):
|
||||
return
|
||||
|
||||
conn = op.get_bind()
|
||||
|
||||
# Step 1: billing_status IS NULL -> 'pending' (batched)
|
||||
while True:
|
||||
result = conn.execute(
|
||||
sa.text("""
|
||||
WITH batch AS (
|
||||
SELECT id FROM usage
|
||||
WHERE billing_status IS NULL
|
||||
LIMIT :batch_size
|
||||
FOR UPDATE SKIP LOCKED
|
||||
)
|
||||
UPDATE usage
|
||||
SET billing_status = 'pending'
|
||||
FROM batch WHERE usage.id = batch.id
|
||||
"""),
|
||||
{"batch_size": BATCH_SIZE},
|
||||
)
|
||||
if result.rowcount < BATCH_SIZE:
|
||||
break
|
||||
|
||||
# Step 2: failed/cancelled zero-cost settled -> void (batched)
|
||||
while True:
|
||||
result = conn.execute(
|
||||
sa.text("""
|
||||
WITH batch AS (
|
||||
SELECT id FROM usage
|
||||
WHERE billing_status = 'settled'
|
||||
AND status IN ('failed', 'cancelled')
|
||||
AND COALESCE(total_cost_usd, 0) = 0
|
||||
AND wallet_balance_after IS NULL
|
||||
LIMIT :batch_size
|
||||
FOR UPDATE SKIP LOCKED
|
||||
)
|
||||
UPDATE usage
|
||||
SET billing_status = 'void',
|
||||
finalized_at = COALESCE(usage.finalized_at, usage.created_at),
|
||||
total_cost_usd = 0,
|
||||
request_cost_usd = 0,
|
||||
actual_total_cost_usd = 0,
|
||||
actual_request_cost_usd = 0
|
||||
FROM batch WHERE usage.id = batch.id
|
||||
"""),
|
||||
{"batch_size": BATCH_SIZE},
|
||||
)
|
||||
if result.rowcount < BATCH_SIZE:
|
||||
break
|
||||
|
||||
# Step 3: backfill finalized_at for terminal rows (batched)
|
||||
while True:
|
||||
result = conn.execute(
|
||||
sa.text("""
|
||||
WITH batch AS (
|
||||
SELECT id FROM usage
|
||||
WHERE billing_status IN ('settled', 'void')
|
||||
AND finalized_at IS NULL
|
||||
LIMIT :batch_size
|
||||
FOR UPDATE SKIP LOCKED
|
||||
)
|
||||
UPDATE usage
|
||||
SET finalized_at = COALESCE(usage.finalized_at, usage.created_at)
|
||||
FROM batch WHERE usage.id = batch.id
|
||||
"""),
|
||||
{"batch_size": BATCH_SIZE},
|
||||
)
|
||||
if result.rowcount < BATCH_SIZE:
|
||||
break
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
if not _table_exists("usage") or not _column_exists("usage", "billing_status"):
|
||||
return
|
||||
|
||||
op.alter_column(
|
||||
"usage",
|
||||
"billing_status",
|
||||
existing_type=sa.String(length=20),
|
||||
server_default="settled",
|
||||
existing_nullable=False,
|
||||
)
|
||||
@@ -0,0 +1,113 @@
|
||||
"""add wallet daily usage ledgers
|
||||
|
||||
Revision ID: d4e5f6a7b8c9
|
||||
Revises: 9e4f1a2b3c4d
|
||||
Create Date: 2026-03-11 21:00:00.000000+00:00
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy import inspect
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "d4e5f6a7b8c9"
|
||||
down_revision: str | None = "9e4f1a2b3c4d"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def _table_exists(table_name: str) -> bool:
|
||||
bind = op.get_bind()
|
||||
insp = inspect(bind)
|
||||
insp.clear_cache()
|
||||
return table_name in insp.get_table_names()
|
||||
|
||||
|
||||
def _column_exists(table_name: str, column_name: str) -> bool:
|
||||
bind = op.get_bind()
|
||||
insp = inspect(bind)
|
||||
insp.clear_cache()
|
||||
return column_name in [col["name"] for col in insp.get_columns(table_name)]
|
||||
|
||||
|
||||
def _index_exists(table_name: str, index_name: str) -> bool:
|
||||
bind = op.get_bind()
|
||||
insp = inspect(bind)
|
||||
insp.clear_cache()
|
||||
return any(idx["name"] == index_name for idx in insp.get_indexes(table_name))
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
if not _table_exists("wallet_daily_usage_ledgers"):
|
||||
op.create_table(
|
||||
"wallet_daily_usage_ledgers",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("wallet_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("billing_date", sa.Date(), nullable=False),
|
||||
sa.Column("billing_timezone", sa.String(length=64), nullable=False),
|
||||
sa.Column("total_cost_usd", sa.Numeric(20, 8), nullable=False, server_default="0"),
|
||||
sa.Column("total_requests", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("input_tokens", sa.BigInteger(), nullable=False, server_default="0"),
|
||||
sa.Column("output_tokens", sa.BigInteger(), nullable=False, server_default="0"),
|
||||
sa.Column("cache_creation_tokens", sa.BigInteger(), nullable=False, server_default="0"),
|
||||
sa.Column("cache_read_tokens", sa.BigInteger(), nullable=False, server_default="0"),
|
||||
sa.Column("first_finalized_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("last_finalized_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("aggregated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(["wallet_id"], ["wallets.id"], ondelete="CASCADE"),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint(
|
||||
"wallet_id",
|
||||
"billing_date",
|
||||
"billing_timezone",
|
||||
name="uq_wallet_daily_usage_ledgers_wallet_date_tz",
|
||||
),
|
||||
)
|
||||
|
||||
if not _index_exists("wallet_daily_usage_ledgers", "idx_wallet_daily_usage_wallet_date"):
|
||||
op.create_index(
|
||||
"idx_wallet_daily_usage_wallet_date",
|
||||
"wallet_daily_usage_ledgers",
|
||||
["wallet_id", "billing_date"],
|
||||
)
|
||||
if not _index_exists("wallet_daily_usage_ledgers", "idx_wallet_daily_usage_date"):
|
||||
op.create_index(
|
||||
"idx_wallet_daily_usage_date",
|
||||
"wallet_daily_usage_ledgers",
|
||||
["billing_date"],
|
||||
)
|
||||
|
||||
if (
|
||||
_table_exists("usage")
|
||||
and all(
|
||||
_column_exists("usage", col) for col in ["billing_status", "finalized_at", "wallet_id"]
|
||||
)
|
||||
and not _index_exists("usage", "idx_usage_billing_finalized_wallet")
|
||||
):
|
||||
op.create_index(
|
||||
"idx_usage_billing_finalized_wallet",
|
||||
"usage",
|
||||
["billing_status", "finalized_at", "wallet_id"],
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
if _table_exists("usage") and _index_exists("usage", "idx_usage_billing_finalized_wallet"):
|
||||
op.drop_index("idx_usage_billing_finalized_wallet", table_name="usage")
|
||||
|
||||
if _table_exists("wallet_daily_usage_ledgers"):
|
||||
if _index_exists("wallet_daily_usage_ledgers", "idx_wallet_daily_usage_date"):
|
||||
op.drop_index("idx_wallet_daily_usage_date", table_name="wallet_daily_usage_ledgers")
|
||||
if _index_exists("wallet_daily_usage_ledgers", "idx_wallet_daily_usage_wallet_date"):
|
||||
op.drop_index(
|
||||
"idx_wallet_daily_usage_wallet_date",
|
||||
table_name="wallet_daily_usage_ledgers",
|
||||
)
|
||||
op.drop_table("wallet_daily_usage_ledgers")
|
||||
@@ -0,0 +1,59 @@
|
||||
"""add provider_api_keys usage total columns
|
||||
|
||||
Revision ID: 9b7c6d5e4f3a
|
||||
Revises: d4e5f6a7b8c9
|
||||
Create Date: 2026-03-11 22:00:00.000000+00:00
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy import inspect
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "9b7c6d5e4f3a"
|
||||
down_revision: str | None = "d4e5f6a7b8c9"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def column_exists(table_name: str, column_name: str) -> bool:
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
columns = [c["name"] for c in inspector.get_columns(table_name)]
|
||||
return column_name in columns
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
if not column_exists("provider_api_keys", "total_tokens"):
|
||||
op.add_column(
|
||||
"provider_api_keys",
|
||||
sa.Column("total_tokens", sa.BigInteger(), nullable=False, server_default="0"),
|
||||
)
|
||||
if column_exists("provider_api_keys", "total_tokens"):
|
||||
op.alter_column("provider_api_keys", "total_tokens", server_default=None)
|
||||
|
||||
if not column_exists("provider_api_keys", "total_cost_usd"):
|
||||
op.add_column(
|
||||
"provider_api_keys",
|
||||
sa.Column(
|
||||
"total_cost_usd",
|
||||
sa.Numeric(20, 8),
|
||||
nullable=False,
|
||||
server_default="0.0",
|
||||
),
|
||||
)
|
||||
if column_exists("provider_api_keys", "total_cost_usd"):
|
||||
op.alter_column("provider_api_keys", "total_cost_usd", server_default=None)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
if column_exists("provider_api_keys", "total_cost_usd"):
|
||||
op.drop_column("provider_api_keys", "total_cost_usd")
|
||||
|
||||
if column_exists("provider_api_keys", "total_tokens"):
|
||||
op.drop_column("provider_api_keys", "total_tokens")
|
||||
@@ -0,0 +1,116 @@
|
||||
"""cleanup stale provider references after provider deletion
|
||||
|
||||
Revision ID: c1d2e3f4a5b6
|
||||
Revises: 9b7c6d5e4f3a
|
||||
Create Date: 2026-03-11 23:00:00.000000+00:00
|
||||
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "c1d2e3f4a5b6"
|
||||
down_revision = "9b7c6d5e4f3a"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
_users = sa.table(
|
||||
"users",
|
||||
sa.column("id", sa.String(36)),
|
||||
sa.column("allowed_providers", sa.JSON()),
|
||||
)
|
||||
_api_keys = sa.table(
|
||||
"api_keys",
|
||||
sa.column("id", sa.String(36)),
|
||||
sa.column("allowed_providers", sa.JSON()),
|
||||
)
|
||||
_user_preferences = sa.table(
|
||||
"user_preferences",
|
||||
sa.column("id", sa.String(36)),
|
||||
sa.column("default_provider_id", sa.String(36)),
|
||||
)
|
||||
_video_tasks = sa.table(
|
||||
"video_tasks",
|
||||
sa.column("id", sa.String(36)),
|
||||
sa.column("provider_id", sa.String(36)),
|
||||
sa.column("endpoint_id", sa.String(36)),
|
||||
)
|
||||
_providers = sa.table("providers", sa.column("id", sa.String(36)))
|
||||
_provider_endpoints = sa.table("provider_endpoints", sa.column("id", sa.String(36)))
|
||||
|
||||
|
||||
def _load_valid_ids(conn: sa.Connection, table: sa.Table) -> set[str]:
|
||||
return {str(row[0]) for row in conn.execute(sa.select(table.c.id)).fetchall() if row[0]}
|
||||
|
||||
|
||||
def _cleanup_allowed_providers(
|
||||
conn: sa.Connection,
|
||||
table: sa.Table,
|
||||
valid_provider_ids: set[str],
|
||||
) -> None:
|
||||
rows = conn.execute(
|
||||
sa.select(table.c.id, table.c.allowed_providers).where(
|
||||
table.c.allowed_providers.isnot(None)
|
||||
)
|
||||
).fetchall()
|
||||
for row_id, allowed_providers in rows:
|
||||
if not isinstance(allowed_providers, list):
|
||||
continue
|
||||
filtered = [
|
||||
provider_id for provider_id in allowed_providers if provider_id in valid_provider_ids
|
||||
]
|
||||
if filtered == allowed_providers:
|
||||
continue
|
||||
conn.execute(table.update().where(table.c.id == row_id).values(allowed_providers=filtered))
|
||||
|
||||
|
||||
def _nullify_missing_fk(
|
||||
conn: sa.Connection,
|
||||
table: sa.Table,
|
||||
id_column: sa.ColumnElement[str],
|
||||
fk_column: sa.ColumnElement[str],
|
||||
valid_ids: set[str],
|
||||
) -> None:
|
||||
rows = conn.execute(sa.select(id_column, fk_column).where(fk_column.isnot(None))).fetchall()
|
||||
invalid_row_ids = [row_id for row_id, fk_value in rows if fk_value not in valid_ids]
|
||||
if not invalid_row_ids:
|
||||
return
|
||||
conn.execute(table.update().where(id_column.in_(invalid_row_ids)).values({fk_column.key: None}))
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
valid_provider_ids = _load_valid_ids(conn, _providers)
|
||||
valid_endpoint_ids = _load_valid_ids(conn, _provider_endpoints)
|
||||
|
||||
_cleanup_allowed_providers(conn, _users, valid_provider_ids)
|
||||
_cleanup_allowed_providers(conn, _api_keys, valid_provider_ids)
|
||||
_nullify_missing_fk(
|
||||
conn,
|
||||
_user_preferences,
|
||||
_user_preferences.c.id,
|
||||
_user_preferences.c.default_provider_id,
|
||||
valid_provider_ids,
|
||||
)
|
||||
_nullify_missing_fk(
|
||||
conn,
|
||||
_video_tasks,
|
||||
_video_tasks.c.id,
|
||||
_video_tasks.c.provider_id,
|
||||
valid_provider_ids,
|
||||
)
|
||||
_nullify_missing_fk(
|
||||
conn,
|
||||
_video_tasks,
|
||||
_video_tasks.c.id,
|
||||
_video_tasks.c.endpoint_id,
|
||||
valid_endpoint_ids,
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
pass
|
||||
@@ -0,0 +1,64 @@
|
||||
"""decouple request_candidates.key_id foreign key from provider_api_keys lifecycle
|
||||
|
||||
Revision ID: b7c8d9e0f1a2
|
||||
Revises: c1d2e3f4a5b6
|
||||
Create Date: 2026-03-12 19:15:00.000000+00:00
|
||||
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "b7c8d9e0f1a2"
|
||||
down_revision = "c1d2e3f4a5b6"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _fk_exists(constraint_name: str, table_name: str) -> bool:
|
||||
bind = op.get_bind()
|
||||
result = bind.execute(
|
||||
sa.text(
|
||||
"SELECT 1 FROM pg_constraint c "
|
||||
"JOIN pg_class r ON c.conrelid = r.oid "
|
||||
"JOIN pg_namespace n ON r.relnamespace = n.oid "
|
||||
"WHERE c.conname = :name AND r.relname = :table "
|
||||
" AND n.nspname = current_schema() AND c.contype = 'f'"
|
||||
),
|
||||
{"name": constraint_name, "table": table_name},
|
||||
)
|
||||
return result.scalar() is not None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
if _fk_exists("request_candidates_key_id_fkey", "request_candidates"):
|
||||
op.drop_constraint(
|
||||
"request_candidates_key_id_fkey", "request_candidates", type_="foreignkey"
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
bind.execute(
|
||||
sa.text(
|
||||
"UPDATE request_candidates rc "
|
||||
"SET key_id = NULL "
|
||||
"WHERE key_id IS NOT NULL "
|
||||
" AND NOT EXISTS ("
|
||||
" SELECT 1 FROM provider_api_keys pak WHERE pak.id = rc.key_id"
|
||||
" )"
|
||||
)
|
||||
)
|
||||
if not _fk_exists("request_candidates_key_id_fkey", "request_candidates"):
|
||||
op.create_foreign_key(
|
||||
"request_candidates_key_id_fkey",
|
||||
"request_candidates",
|
||||
"provider_api_keys",
|
||||
["key_id"],
|
||||
["id"],
|
||||
ondelete="CASCADE",
|
||||
)
|
||||
@@ -0,0 +1,54 @@
|
||||
"""add user rate_limit and backfill normal api key limits
|
||||
|
||||
Revision ID: b7e8f9a0c1d2
|
||||
Revises: b7c8d9e0f1a2
|
||||
Create Date: 2026-03-13 12:00:00.000000+00:00
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy import inspect
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "b7e8f9a0c1d2"
|
||||
down_revision: str | None = "b7c8d9e0f1a2"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def column_exists(table_name: str, column_name: str) -> bool:
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
columns = [c["name"] for c in inspector.get_columns(table_name)]
|
||||
return column_name in columns
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
if not column_exists("users", "rate_limit"):
|
||||
op.add_column("users", sa.Column("rate_limit", sa.Integer(), nullable=True))
|
||||
|
||||
# 普通 Key 新语义不再允许 NULL;存量 NULL 统一回填为 0(不限制)。
|
||||
op.execute(sa.text("""
|
||||
UPDATE api_keys
|
||||
SET rate_limit = 0
|
||||
WHERE is_standalone = FALSE
|
||||
AND rate_limit IS NULL
|
||||
"""))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# 恢复普通 Key 的 rate_limit 为 NULL(与 upgrade 中回填 0 对应)
|
||||
op.execute(sa.text("""
|
||||
UPDATE api_keys
|
||||
SET rate_limit = NULL
|
||||
WHERE is_standalone = FALSE
|
||||
AND rate_limit = 0
|
||||
"""))
|
||||
|
||||
if column_exists("users", "rate_limit"):
|
||||
op.drop_column("users", "rate_limit")
|
||||
@@ -0,0 +1,87 @@
|
||||
"""add user sessions table for device-level auth
|
||||
|
||||
Revision ID: f6e7d8c9b0a1
|
||||
Revises: b7e8f9a0c1d2
|
||||
Create Date: 2026-03-15 12:00:00.000000+00:00
|
||||
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "f6e7d8c9b0a1"
|
||||
down_revision = "b7e8f9a0c1d2"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
inspector = sa.inspect(bind)
|
||||
if "user_sessions" in inspector.get_table_names():
|
||||
return
|
||||
|
||||
op.create_table(
|
||||
"user_sessions",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("user_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("client_device_id", sa.String(length=128), nullable=False),
|
||||
sa.Column("device_label", sa.String(length=120), nullable=True),
|
||||
sa.Column("device_type", sa.String(length=20), nullable=False, server_default="unknown"),
|
||||
sa.Column("browser_name", sa.String(length=50), nullable=True),
|
||||
sa.Column("browser_version", sa.String(length=50), nullable=True),
|
||||
sa.Column("os_name", sa.String(length=50), nullable=True),
|
||||
sa.Column("os_version", sa.String(length=50), nullable=True),
|
||||
sa.Column("device_model", sa.String(length=100), nullable=True),
|
||||
sa.Column("ip_address", sa.String(length=45), nullable=True),
|
||||
sa.Column("user_agent", sa.String(length=1000), nullable=True),
|
||||
sa.Column("client_hints", sa.JSON(), nullable=True),
|
||||
sa.Column("refresh_token_hash", sa.String(length=64), nullable=False),
|
||||
sa.Column("prev_refresh_token_hash", sa.String(length=64), nullable=True),
|
||||
sa.Column("rotated_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column(
|
||||
"last_seen_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()
|
||||
),
|
||||
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("revoke_reason", sa.String(length=100), nullable=True),
|
||||
sa.Column(
|
||||
"created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()
|
||||
),
|
||||
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index("ix_user_sessions_user_id", "user_sessions", ["user_id"], unique=False)
|
||||
op.create_index(
|
||||
"ix_user_sessions_client_device_id",
|
||||
"user_sessions",
|
||||
["client_device_id"],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(
|
||||
"idx_user_sessions_user_active",
|
||||
"user_sessions",
|
||||
["user_id", "revoked_at", "expires_at"],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(
|
||||
"idx_user_sessions_user_device",
|
||||
"user_sessions",
|
||||
["user_id", "client_device_id"],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("idx_user_sessions_user_device", table_name="user_sessions")
|
||||
op.drop_index("idx_user_sessions_user_active", table_name="user_sessions")
|
||||
op.drop_index("ix_user_sessions_client_device_id", table_name="user_sessions")
|
||||
op.drop_index("ix_user_sessions_user_id", table_name="user_sessions")
|
||||
op.drop_table("user_sessions")
|
||||
@@ -0,0 +1,41 @@
|
||||
"""add status_snapshot column to provider_api_keys
|
||||
|
||||
Revision ID: c9d8e7f6a5b4
|
||||
Revises: f6e7d8c9b0a1
|
||||
Create Date: 2026-03-20 12:00:00.000000
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy import inspect
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "c9d8e7f6a5b4"
|
||||
down_revision: str | None = "f6e7d8c9b0a1"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def column_exists(table_name: str, column_name: str) -> bool:
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
columns = [c["name"] for c in inspector.get_columns(table_name)]
|
||||
return column_name in columns
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
if not column_exists("provider_api_keys", "status_snapshot"):
|
||||
op.add_column(
|
||||
"provider_api_keys",
|
||||
sa.Column("status_snapshot", sa.JSON(), nullable=True),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
if column_exists("provider_api_keys", "status_snapshot"):
|
||||
op.drop_column("provider_api_keys", "status_snapshot")
|
||||
85
_deprecated_py_src/alembic/versions/README.md
Normal file
85
_deprecated_py_src/alembic/versions/README.md
Normal file
@@ -0,0 +1,85 @@
|
||||
# Aether - 数据库迁移说明
|
||||
|
||||
## 当前版本
|
||||
|
||||
- **Revision ID**: `aether_baseline`
|
||||
- **创建日期**: 2025-12-06
|
||||
- **状态**: 全新基线
|
||||
|
||||
## 迁移历史
|
||||
|
||||
所有历史增量迁移已清理,当前以完整 schema 作为新起点。
|
||||
|
||||
## 核心数据库结构
|
||||
|
||||
### 用户系统
|
||||
- **users**: 用户账户管理
|
||||
- **api_keys**: API 密钥管理
|
||||
- **wallets**: 统一钱包账户(充值余额/赠款余额/无限制模式)
|
||||
- **user_preferences**: 用户偏好设置
|
||||
|
||||
### Provider 三层架构
|
||||
- **providers**: LLM 提供商配置
|
||||
- **provider_endpoints**: Provider 的 API 端点配置
|
||||
- **provider_api_keys**: Endpoint 的具体 API 密钥
|
||||
- **api_key_provider_mappings**: 用户 API Key 到 Provider 的映射关系
|
||||
|
||||
### 模型系统
|
||||
- **global_models**: 统一模型定义(GlobalModel)
|
||||
- **models**: Provider 的模型实现和价格配置
|
||||
- **model_mappings**: 统一的别名与降级映射表
|
||||
|
||||
### 监控和追踪
|
||||
- **usage**: API 使用记录
|
||||
- **request_candidates**: 请求候选记录
|
||||
- **provider_usage_tracking**: Provider 使用统计
|
||||
- **audit_logs**: 系统审计日志
|
||||
|
||||
### 系统功能
|
||||
- **announcements**: 系统公告
|
||||
- **announcement_reads**: 公告阅读记录
|
||||
- **system_configs**: 系统配置
|
||||
|
||||
## 从旧数据库迁移
|
||||
|
||||
如需从旧数据库迁移数据,请使用迁移脚本:
|
||||
|
||||
```bash
|
||||
# 设置环境变量
|
||||
export OLD_DATABASE_URL="postgresql://user:pass@old-host:5432/old_db"
|
||||
export NEW_DATABASE_URL="postgresql://user:pass@new-host:5432/aether"
|
||||
|
||||
# 干运行(查看迁移量)
|
||||
python scripts/migrate_data.py --dry-run
|
||||
|
||||
# 执行迁移
|
||||
python scripts/migrate_data.py
|
||||
|
||||
# 只迁移特定表
|
||||
python scripts/migrate_data.py --tables users,providers,api_keys
|
||||
|
||||
# 跳过大表
|
||||
python scripts/migrate_data.py --skip usage,audit_logs
|
||||
```
|
||||
|
||||
## 新数据库初始化
|
||||
|
||||
```bash
|
||||
# 1. 运行迁移创建表结构
|
||||
DATABASE_URL="postgresql://user:pass@host:5432/aether" uv run alembic upgrade head
|
||||
|
||||
# 2. 初始化管理员账户
|
||||
python -m src.database.init_db
|
||||
```
|
||||
|
||||
## 未来迁移
|
||||
|
||||
基于 `aether_baseline` 创建增量迁移:
|
||||
|
||||
```bash
|
||||
# 修改模型后,生成新的迁移
|
||||
DATABASE_URL="..." uv run alembic revision --autogenerate -m "描述变更"
|
||||
|
||||
# 应用迁移
|
||||
DATABASE_URL="..." uv run alembic upgrade head
|
||||
```
|
||||
0
_deprecated_py_src/api/__init__.py
Normal file
0
_deprecated_py_src/api/__init__.py
Normal file
334
_deprecated_py_src/api/admin/__init__.py
Normal file
334
_deprecated_py_src/api/admin/__init__.py
Normal file
@@ -0,0 +1,334 @@
|
||||
"""Admin API routers.
|
||||
|
||||
The admin surface remains Python-only host/control-plane scope. It is not part
|
||||
of the compatibility frontdoor manifest that Rust is preparing to absorb.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter
|
||||
from starlette.routing import BaseRoute
|
||||
|
||||
from .adaptive import router as adaptive_router
|
||||
from .api_keys import router as api_keys_router
|
||||
from .billing import router as billing_router
|
||||
from .endpoints import router as endpoints_router
|
||||
from .models import router as models_router
|
||||
from .modules import router as modules_router
|
||||
from .monitoring import router as monitoring_router
|
||||
from .payments import router as payments_router
|
||||
from .pool import router as pool_router
|
||||
from .provider_oauth import router as provider_oauth_router
|
||||
from .provider_ops import router as provider_ops_router
|
||||
from .provider_query import router as provider_query_router
|
||||
from .provider_strategy import router as provider_strategy_router
|
||||
from .providers import router as providers_router
|
||||
from .security import router as security_router
|
||||
from .stats import router as stats_router
|
||||
from .system import router as system_router
|
||||
from .usage import router as usage_router
|
||||
from .users import router as users_router
|
||||
from .video_tasks import router as video_tasks_router
|
||||
from .wallets import router as wallets_router
|
||||
|
||||
_RUST_OWNED_ADMIN_ROUTE_SIGNATURES = frozenset(
|
||||
{
|
||||
("GET", "/api/admin/modules/status"),
|
||||
("GET", "/api/admin/modules/status/{module_name}"),
|
||||
("PUT", "/api/admin/modules/status/{module_name}/enabled"),
|
||||
("GET", "/api/admin/system/version"),
|
||||
("GET", "/api/admin/system/check-update"),
|
||||
("GET", "/api/admin/system/aws-regions"),
|
||||
("GET", "/api/admin/system/stats"),
|
||||
("GET", "/api/admin/system/settings"),
|
||||
("GET", "/api/admin/system/config/export"),
|
||||
("GET", "/api/admin/system/users/export"),
|
||||
("POST", "/api/admin/system/config/import"),
|
||||
("POST", "/api/admin/system/users/import"),
|
||||
("POST", "/api/admin/system/smtp/test"),
|
||||
("POST", "/api/admin/system/cleanup"),
|
||||
("POST", "/api/admin/system/purge/config"),
|
||||
("POST", "/api/admin/system/purge/users"),
|
||||
("POST", "/api/admin/system/purge/usage"),
|
||||
("POST", "/api/admin/system/purge/audit-logs"),
|
||||
("POST", "/api/admin/system/purge/request-bodies"),
|
||||
("POST", "/api/admin/system/purge/stats"),
|
||||
("PUT", "/api/admin/system/settings"),
|
||||
("GET", "/api/admin/system/configs"),
|
||||
("GET", "/api/admin/system/configs/{key}"),
|
||||
("PUT", "/api/admin/system/configs/{key}"),
|
||||
("DELETE", "/api/admin/system/configs/{key}"),
|
||||
("GET", "/api/admin/system/api-formats"),
|
||||
("GET", "/api/admin/system/email/templates"),
|
||||
("GET", "/api/admin/system/email/templates/{template_type}"),
|
||||
("PUT", "/api/admin/system/email/templates/{template_type}"),
|
||||
("POST", "/api/admin/system/email/templates/{template_type}/preview"),
|
||||
("POST", "/api/admin/system/email/templates/{template_type}/reset"),
|
||||
("GET", "/api/admin/providers/"),
|
||||
("POST", "/api/admin/providers/"),
|
||||
("PATCH", "/api/admin/providers/{provider_id}"),
|
||||
("DELETE", "/api/admin/providers/{provider_id}"),
|
||||
("GET", "/api/admin/providers/summary"),
|
||||
("GET", "/api/admin/providers/{provider_id}/summary"),
|
||||
("GET", "/api/admin/providers/{provider_id}/health-monitor"),
|
||||
("GET", "/api/admin/providers/{provider_id}/mapping-preview"),
|
||||
("GET", "/api/admin/providers/{provider_id}/delete-task/{task_id}"),
|
||||
("GET", "/api/admin/providers/{provider_id}/pool-status"),
|
||||
("POST", "/api/admin/providers/{provider_id}/pool/clear-cooldown/{key_id}"),
|
||||
("POST", "/api/admin/providers/{provider_id}/pool/reset-cost/{key_id}"),
|
||||
("GET", "/api/admin/providers/{provider_id}/models"),
|
||||
("POST", "/api/admin/providers/{provider_id}/models"),
|
||||
("GET", "/api/admin/providers/{provider_id}/models/{model_id}"),
|
||||
("PATCH", "/api/admin/providers/{provider_id}/models/{model_id}"),
|
||||
("DELETE", "/api/admin/providers/{provider_id}/models/{model_id}"),
|
||||
("POST", "/api/admin/providers/{provider_id}/models/batch"),
|
||||
("GET", "/api/admin/providers/{provider_id}/available-source-models"),
|
||||
("POST", "/api/admin/providers/{provider_id}/assign-global-models"),
|
||||
("POST", "/api/admin/providers/{provider_id}/import-from-upstream"),
|
||||
("GET", "/api/admin/endpoints/providers/{provider_id}/endpoints"),
|
||||
("POST", "/api/admin/endpoints/providers/{provider_id}/endpoints"),
|
||||
("GET", "/api/admin/endpoints/defaults/{api_format}/body-rules"),
|
||||
("GET", "/api/admin/endpoints/{endpoint_id}"),
|
||||
("PUT", "/api/admin/endpoints/{endpoint_id}"),
|
||||
("DELETE", "/api/admin/endpoints/{endpoint_id}"),
|
||||
("PUT", "/api/admin/endpoints/keys/{key_id}"),
|
||||
("GET", "/api/admin/endpoints/keys/grouped-by-format"),
|
||||
("GET", "/api/admin/endpoints/keys/{key_id}/reveal"),
|
||||
("GET", "/api/admin/endpoints/keys/{key_id}/export"),
|
||||
("DELETE", "/api/admin/endpoints/keys/{key_id}"),
|
||||
("POST", "/api/admin/endpoints/keys/batch-delete"),
|
||||
("POST", "/api/admin/endpoints/keys/{key_id}/clear-oauth-invalid"),
|
||||
("GET", "/api/admin/endpoints/providers/{provider_id}/keys"),
|
||||
("POST", "/api/admin/endpoints/providers/{provider_id}/keys"),
|
||||
("POST", "/api/admin/endpoints/providers/{provider_id}/refresh-quota"),
|
||||
("GET", "/api/admin/endpoints/rpm/key/{key_id}"),
|
||||
("DELETE", "/api/admin/endpoints/rpm/key/{key_id}"),
|
||||
("GET", "/api/admin/endpoints/health/summary"),
|
||||
("GET", "/api/admin/endpoints/health/status"),
|
||||
("GET", "/api/admin/endpoints/health/api-formats"),
|
||||
("GET", "/api/admin/endpoints/health/key/{key_id}"),
|
||||
("PATCH", "/api/admin/endpoints/health/keys/{key_id}"),
|
||||
("PATCH", "/api/admin/endpoints/health/keys"),
|
||||
("GET", "/api/admin/provider-oauth/supported-types"),
|
||||
("POST", "/api/admin/provider-oauth/keys/{key_id}/start"),
|
||||
("POST", "/api/admin/provider-oauth/keys/{key_id}/complete"),
|
||||
("POST", "/api/admin/provider-oauth/keys/{key_id}/refresh"),
|
||||
("POST", "/api/admin/provider-oauth/providers/{provider_id}/start"),
|
||||
("POST", "/api/admin/provider-oauth/providers/{provider_id}/complete"),
|
||||
("POST", "/api/admin/provider-oauth/providers/{provider_id}/import-refresh-token"),
|
||||
("POST", "/api/admin/provider-oauth/providers/{provider_id}/device-authorize"),
|
||||
("POST", "/api/admin/provider-oauth/providers/{provider_id}/device-poll"),
|
||||
("POST", "/api/admin/provider-oauth/providers/{provider_id}/batch-import"),
|
||||
("POST", "/api/admin/provider-oauth/providers/{provider_id}/batch-import/tasks"),
|
||||
("GET", "/api/admin/provider-oauth/providers/{provider_id}/batch-import/tasks/{task_id}"),
|
||||
("GET", "/api/admin/adaptive/keys"),
|
||||
("PATCH", "/api/admin/adaptive/keys/{key_id}/mode"),
|
||||
("GET", "/api/admin/adaptive/keys/{key_id}/stats"),
|
||||
("DELETE", "/api/admin/adaptive/keys/{key_id}/learning"),
|
||||
("PATCH", "/api/admin/adaptive/keys/{key_id}/limit"),
|
||||
("GET", "/api/admin/adaptive/summary"),
|
||||
("GET", "/api/admin/provider-ops/architectures"),
|
||||
("GET", "/api/admin/provider-ops/architectures/{architecture_id}"),
|
||||
("GET", "/api/admin/provider-ops/providers/{provider_id}/status"),
|
||||
("GET", "/api/admin/provider-ops/providers/{provider_id}/config"),
|
||||
("PUT", "/api/admin/provider-ops/providers/{provider_id}/config"),
|
||||
("DELETE", "/api/admin/provider-ops/providers/{provider_id}/config"),
|
||||
("POST", "/api/admin/provider-ops/providers/{provider_id}/connect"),
|
||||
("POST", "/api/admin/provider-ops/providers/{provider_id}/disconnect"),
|
||||
("POST", "/api/admin/provider-ops/providers/{provider_id}/verify"),
|
||||
("POST", "/api/admin/provider-ops/providers/{provider_id}/actions/{action_type}"),
|
||||
("GET", "/api/admin/provider-ops/providers/{provider_id}/balance"),
|
||||
("POST", "/api/admin/provider-ops/providers/{provider_id}/balance"),
|
||||
("POST", "/api/admin/provider-ops/providers/{provider_id}/checkin"),
|
||||
("POST", "/api/admin/provider-ops/batch/balance"),
|
||||
("GET", "/api/admin/billing/presets"),
|
||||
("POST", "/api/admin/billing/presets/apply"),
|
||||
("GET", "/api/admin/billing/rules"),
|
||||
("GET", "/api/admin/billing/rules/{rule_id}"),
|
||||
("POST", "/api/admin/billing/rules"),
|
||||
("PUT", "/api/admin/billing/rules/{rule_id}"),
|
||||
("GET", "/api/admin/billing/collectors"),
|
||||
("GET", "/api/admin/billing/collectors/{collector_id}"),
|
||||
("POST", "/api/admin/billing/collectors"),
|
||||
("PUT", "/api/admin/billing/collectors/{collector_id}"),
|
||||
("PUT", "/api/admin/provider-strategy/providers/{provider_id}/billing"),
|
||||
("GET", "/api/admin/provider-strategy/providers/{provider_id}/stats"),
|
||||
("GET", "/api/admin/provider-strategy/strategies"),
|
||||
("DELETE", "/api/admin/provider-strategy/providers/{provider_id}/quota"),
|
||||
("POST", "/api/admin/provider-query/models"),
|
||||
("POST", "/api/admin/provider-query/test-model"),
|
||||
("POST", "/api/admin/provider-query/test-model-failover"),
|
||||
("GET", "/api/admin/payments/orders"),
|
||||
("GET", "/api/admin/payments/orders/{order_id}"),
|
||||
("POST", "/api/admin/payments/orders/{order_id}/expire"),
|
||||
("POST", "/api/admin/payments/orders/{order_id}/credit"),
|
||||
("POST", "/api/admin/payments/orders/{order_id}/fail"),
|
||||
("GET", "/api/admin/payments/callbacks"),
|
||||
("POST", "/api/admin/security/ip/blacklist"),
|
||||
("DELETE", "/api/admin/security/ip/blacklist/{ip_address}"),
|
||||
("GET", "/api/admin/security/ip/blacklist/stats"),
|
||||
("POST", "/api/admin/security/ip/whitelist"),
|
||||
("DELETE", "/api/admin/security/ip/whitelist/{ip_address}"),
|
||||
("GET", "/api/admin/security/ip/whitelist"),
|
||||
("GET", "/api/admin/stats/providers/quota-usage"),
|
||||
("GET", "/api/admin/stats/comparison"),
|
||||
("GET", "/api/admin/stats/errors/distribution"),
|
||||
("GET", "/api/admin/stats/performance/percentiles"),
|
||||
("GET", "/api/admin/stats/cost/forecast"),
|
||||
("GET", "/api/admin/stats/cost/savings"),
|
||||
("GET", "/api/admin/stats/leaderboard/api-keys"),
|
||||
("GET", "/api/admin/stats/leaderboard/models"),
|
||||
("GET", "/api/admin/stats/leaderboard/users"),
|
||||
("GET", "/api/admin/stats/time-series"),
|
||||
("GET", "/api/admin/monitoring/audit-logs"),
|
||||
("GET", "/api/admin/monitoring/system-status"),
|
||||
("GET", "/api/admin/monitoring/suspicious-activities"),
|
||||
("GET", "/api/admin/monitoring/user-behavior/{user_id}"),
|
||||
("GET", "/api/admin/monitoring/resilience-status"),
|
||||
("GET", "/api/admin/monitoring/resilience/circuit-history"),
|
||||
("DELETE", "/api/admin/monitoring/resilience/error-stats"),
|
||||
("GET", "/api/admin/monitoring/trace/{request_id}"),
|
||||
("GET", "/api/admin/monitoring/trace/stats/provider/{provider_id}"),
|
||||
("GET", "/api/admin/monitoring/cache/stats"),
|
||||
("GET", "/api/admin/monitoring/cache/affinity/{user_identifier}"),
|
||||
("GET", "/api/admin/monitoring/cache/affinities"),
|
||||
("DELETE", "/api/admin/monitoring/cache/users/{user_identifier}"),
|
||||
(
|
||||
"DELETE",
|
||||
"/api/admin/monitoring/cache/affinity/{affinity_key}/{endpoint_id}/{model_id}/{api_format}",
|
||||
),
|
||||
("DELETE", "/api/admin/monitoring/cache"),
|
||||
("DELETE", "/api/admin/monitoring/cache/providers/{provider_id}"),
|
||||
("GET", "/api/admin/monitoring/cache/config"),
|
||||
("GET", "/api/admin/monitoring/cache/metrics"),
|
||||
("GET", "/api/admin/monitoring/cache/model-mapping/stats"),
|
||||
("DELETE", "/api/admin/monitoring/cache/model-mapping"),
|
||||
("DELETE", "/api/admin/monitoring/cache/model-mapping/{model_name}"),
|
||||
(
|
||||
"DELETE",
|
||||
"/api/admin/monitoring/cache/model-mapping/provider/{provider_id}/{global_model_id}",
|
||||
),
|
||||
("GET", "/api/admin/monitoring/cache/redis-keys"),
|
||||
("DELETE", "/api/admin/monitoring/cache/redis-keys/{category}"),
|
||||
("GET", "/api/admin/usage/aggregation/stats"),
|
||||
("GET", "/api/admin/usage/stats"),
|
||||
("GET", "/api/admin/usage/heatmap"),
|
||||
("GET", "/api/admin/usage/records"),
|
||||
("GET", "/api/admin/usage/active"),
|
||||
("GET", "/api/admin/usage/cache-affinity/hit-analysis"),
|
||||
("GET", "/api/admin/usage/cache-affinity/interval-timeline"),
|
||||
("GET", "/api/admin/usage/cache-affinity/ttl-analysis"),
|
||||
("GET", "/api/admin/usage/{usage_id}/curl"),
|
||||
("GET", "/api/admin/usage/{usage_id}"),
|
||||
("POST", "/api/admin/usage/{usage_id}/replay"),
|
||||
("GET", "/api/admin/video-tasks"),
|
||||
("GET", "/api/admin/video-tasks/stats"),
|
||||
("GET", "/api/admin/video-tasks/{task_id}"),
|
||||
("POST", "/api/admin/video-tasks/{task_id}/cancel"),
|
||||
("GET", "/api/admin/video-tasks/{task_id}/video"),
|
||||
("GET", "/api/admin/wallets"),
|
||||
("GET", "/api/admin/wallets/ledger"),
|
||||
("GET", "/api/admin/wallets/refund-requests"),
|
||||
("GET", "/api/admin/wallets/{wallet_id}"),
|
||||
("GET", "/api/admin/wallets/{wallet_id}/transactions"),
|
||||
("GET", "/api/admin/wallets/{wallet_id}/refunds"),
|
||||
("POST", "/api/admin/wallets/{wallet_id}/adjust"),
|
||||
("POST", "/api/admin/wallets/{wallet_id}/recharge"),
|
||||
("POST", "/api/admin/wallets/{wallet_id}/refunds/{refund_id}/process"),
|
||||
("POST", "/api/admin/wallets/{wallet_id}/refunds/{refund_id}/complete"),
|
||||
("POST", "/api/admin/wallets/{wallet_id}/refunds/{refund_id}/fail"),
|
||||
("GET", "/api/admin/api-keys"),
|
||||
("POST", "/api/admin/api-keys"),
|
||||
("GET", "/api/admin/api-keys/{key_id}"),
|
||||
("PUT", "/api/admin/api-keys/{key_id}"),
|
||||
("PATCH", "/api/admin/api-keys/{key_id}"),
|
||||
("DELETE", "/api/admin/api-keys/{key_id}"),
|
||||
("GET", "/api/admin/users"),
|
||||
("POST", "/api/admin/users"),
|
||||
("GET", "/api/admin/users/{user_id}"),
|
||||
("PUT", "/api/admin/users/{user_id}"),
|
||||
("DELETE", "/api/admin/users/{user_id}"),
|
||||
("GET", "/api/admin/users/{user_id}/sessions"),
|
||||
("DELETE", "/api/admin/users/{user_id}/sessions"),
|
||||
("DELETE", "/api/admin/users/{user_id}/sessions/{session_id}"),
|
||||
("GET", "/api/admin/users/{user_id}/api-keys"),
|
||||
("POST", "/api/admin/users/{user_id}/api-keys"),
|
||||
("DELETE", "/api/admin/users/{user_id}/api-keys/{key_id}"),
|
||||
("PUT", "/api/admin/users/{user_id}/api-keys/{key_id}"),
|
||||
("PATCH", "/api/admin/users/{user_id}/api-keys/{key_id}/lock"),
|
||||
("GET", "/api/admin/users/{user_id}/api-keys/{key_id}/full-key"),
|
||||
("GET", "/api/admin/pool/overview"),
|
||||
("GET", "/api/admin/pool/scheduling-presets"),
|
||||
("GET", "/api/admin/pool/{provider_id}/keys"),
|
||||
("GET", "/api/admin/pool/{provider_id}/keys/batch-delete-task/{task_id}"),
|
||||
("POST", "/api/admin/pool/{provider_id}/keys/batch-action"),
|
||||
("POST", "/api/admin/pool/{provider_id}/keys/batch-import"),
|
||||
("POST", "/api/admin/pool/{provider_id}/keys/cleanup-banned"),
|
||||
("POST", "/api/admin/pool/{provider_id}/keys/resolve-selection"),
|
||||
("GET", "/api/admin/proxy-nodes"),
|
||||
("GET", "/api/admin/models/catalog"),
|
||||
("GET", "/api/admin/models/external"),
|
||||
("DELETE", "/api/admin/models/external/cache"),
|
||||
("GET", "/api/admin/models/global"),
|
||||
("POST", "/api/admin/models/global"),
|
||||
("GET", "/api/admin/models/global/{global_model_id}"),
|
||||
("PATCH", "/api/admin/models/global/{global_model_id}"),
|
||||
("DELETE", "/api/admin/models/global/{global_model_id}"),
|
||||
("POST", "/api/admin/models/global/batch-delete"),
|
||||
("POST", "/api/admin/models/global/{global_model_id}/assign-to-providers"),
|
||||
("GET", "/api/admin/models/global/{global_model_id}/providers"),
|
||||
("GET", "/api/admin/models/global/{global_model_id}/routing"),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _route_is_rust_owned(route: BaseRoute) -> bool:
|
||||
path = getattr(route, "path", None)
|
||||
methods = getattr(route, "methods", None)
|
||||
if not isinstance(path, str) or not methods:
|
||||
return False
|
||||
return any(
|
||||
(method, path) in _RUST_OWNED_ADMIN_ROUTE_SIGNATURES
|
||||
for method in methods
|
||||
if method not in {"HEAD", "OPTIONS"}
|
||||
)
|
||||
|
||||
|
||||
def _build_python_admin_router() -> APIRouter:
|
||||
"""Admin/control-plane routes that still require the Python host."""
|
||||
admin_router = APIRouter()
|
||||
admin_router.include_router(system_router)
|
||||
admin_router.include_router(users_router)
|
||||
admin_router.include_router(providers_router)
|
||||
admin_router.include_router(api_keys_router)
|
||||
admin_router.include_router(billing_router)
|
||||
admin_router.include_router(usage_router)
|
||||
admin_router.include_router(monitoring_router)
|
||||
admin_router.include_router(payments_router)
|
||||
admin_router.include_router(endpoints_router)
|
||||
admin_router.include_router(provider_strategy_router)
|
||||
admin_router.include_router(provider_oauth_router)
|
||||
admin_router.include_router(adaptive_router)
|
||||
admin_router.include_router(models_router)
|
||||
admin_router.include_router(security_router)
|
||||
admin_router.include_router(stats_router)
|
||||
admin_router.include_router(provider_query_router)
|
||||
admin_router.include_router(modules_router)
|
||||
admin_router.include_router(pool_router)
|
||||
admin_router.include_router(provider_ops_router)
|
||||
admin_router.include_router(video_tasks_router)
|
||||
admin_router.include_router(wallets_router)
|
||||
admin_router.routes = [route for route in admin_router.routes if not _route_is_rust_owned(route)]
|
||||
return admin_router
|
||||
|
||||
|
||||
# Admin/control-plane 在本轮 frontdoor cutover 后仍保留在 Python 宿主。
|
||||
python_admin_router = _build_python_admin_router()
|
||||
router = python_admin_router
|
||||
|
||||
# 注意:以下路由已迁移到模块系统,由 ModuleRegistry 动态注册
|
||||
# - ldap_router: 当 LDAP_AVAILABLE=true 时注册
|
||||
# - management_tokens_router: 当 MANAGEMENT_TOKENS_AVAILABLE=true 时注册
|
||||
# - proxy_nodes_router: 当 PROXY_NODES_AVAILABLE=true 时注册
|
||||
|
||||
__all__ = ["python_admin_router", "router"]
|
||||
411
_deprecated_py_src/api/admin/adaptive.py
Normal file
411
_deprecated_py_src/api/admin/adaptive.py
Normal file
@@ -0,0 +1,411 @@
|
||||
"""
|
||||
自适应 RPM 管理 API 端点
|
||||
|
||||
设计原则:
|
||||
- 自适应模式由 rpm_limit 字段决定:
|
||||
- rpm_limit = NULL:启用自适应模式,系统自动学习并调整 RPM 限制
|
||||
- rpm_limit = 数字:固定限制模式,使用用户指定的 RPM 限制
|
||||
- learned_rpm_limit:自适应模式下学习到的 RPM 限制值
|
||||
- adaptive_mode 是计算字段,基于 rpm_limit 是否为 NULL
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||
from pydantic import BaseModel, Field, ValidationError
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.orm import Session, load_only
|
||||
|
||||
from src.api.base.admin_adapter import AdminApiAdapter
|
||||
from src.api.base.context import ApiRequestContext
|
||||
from src.api.base.pipeline import get_pipeline
|
||||
from src.core.exceptions import InvalidRequestException, translate_pydantic_error
|
||||
from src.database import get_db
|
||||
from src.models.database import ProviderAPIKey
|
||||
from src.services.rate_limit.adaptive_rpm import get_adaptive_rpm_manager
|
||||
|
||||
router = APIRouter(prefix="/api/admin/adaptive", tags=["Adaptive RPM"])
|
||||
pipeline = get_pipeline()
|
||||
|
||||
|
||||
# ==================== Pydantic Models ====================
|
||||
|
||||
|
||||
class EnableAdaptiveRequest(BaseModel):
|
||||
"""启用自适应模式请求"""
|
||||
|
||||
enabled: bool = Field(..., description="是否启用自适应模式(true=自适应,false=固定限制)")
|
||||
fixed_limit: int | None = Field(
|
||||
None, ge=1, le=100, description="固定 RPM 限制(仅当 enabled=false 时生效,1-100)"
|
||||
)
|
||||
|
||||
|
||||
class AdaptiveStatsResponse(BaseModel):
|
||||
"""自适应统计响应"""
|
||||
|
||||
adaptive_mode: bool = Field(..., description="是否为自适应模式(rpm_limit=NULL)")
|
||||
rpm_limit: int | None = Field(None, description="用户配置的固定限制(NULL=自适应)")
|
||||
effective_limit: int | None = Field(
|
||||
None, description="当前有效限制(自适应使用学习值,固定使用配置值)"
|
||||
)
|
||||
learned_limit: int | None = Field(None, description="学习到的 RPM 限制")
|
||||
concurrent_429_count: int
|
||||
rpm_429_count: int
|
||||
last_429_at: str | None
|
||||
last_429_type: str | None
|
||||
adjustment_count: int
|
||||
recent_adjustments: list[dict]
|
||||
# 置信度相关
|
||||
learning_confidence: float | None = Field(None, description="学习置信度 (0.0-1.0)")
|
||||
enforcement_active: bool | None = Field(None, description="是否正在执行本地 RPM 限制")
|
||||
observation_count: int = Field(0, description="429 观察记录数")
|
||||
header_observation_count: int = Field(0, description="带 header 的观察记录数")
|
||||
latest_upstream_limit: int | None = Field(None, description="最近一次上游 header 限制值")
|
||||
|
||||
|
||||
class KeyListItem(BaseModel):
|
||||
"""Key 列表项"""
|
||||
|
||||
id: str
|
||||
name: str | None
|
||||
provider_id: str
|
||||
api_formats: list[str] = Field(default_factory=list)
|
||||
is_adaptive: bool = Field(..., description="是否为自适应模式(rpm_limit=NULL)")
|
||||
rpm_limit: int | None = Field(None, description="固定 RPM 限制(NULL=自适应)")
|
||||
effective_limit: int | None = Field(None, description="当前有效限制")
|
||||
learned_rpm_limit: int | None = Field(None, description="学习到的 RPM 限制")
|
||||
concurrent_429_count: int
|
||||
rpm_429_count: int
|
||||
|
||||
|
||||
# ==================== API Endpoints ====================
|
||||
|
||||
|
||||
@router.get(
|
||||
"/keys",
|
||||
response_model=list[KeyListItem],
|
||||
summary="获取所有启用自适应模式的Key",
|
||||
)
|
||||
async def list_adaptive_keys(
|
||||
request: Request,
|
||||
provider_id: str | None = Query(None, description="按 Provider 过滤"),
|
||||
db: Session = Depends(get_db),
|
||||
) -> Any:
|
||||
"""
|
||||
获取所有启用自适应模式的Key列表
|
||||
|
||||
可选参数:
|
||||
- provider_id: 按 Provider 过滤
|
||||
"""
|
||||
adapter = ListAdaptiveKeysAdapter(provider_id=provider_id)
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
|
||||
@router.patch(
|
||||
"/keys/{key_id}/mode",
|
||||
summary="Toggle key's RPM control mode",
|
||||
)
|
||||
async def toggle_adaptive_mode(
|
||||
key_id: str,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
) -> Any:
|
||||
"""
|
||||
Toggle the RPM control mode for a specific key
|
||||
|
||||
Parameters:
|
||||
- enabled: true=adaptive mode (rpm_limit=NULL), false=fixed limit mode
|
||||
- fixed_limit: fixed limit value (required when enabled=false)
|
||||
"""
|
||||
adapter = ToggleAdaptiveModeAdapter(key_id=key_id)
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/keys/{key_id}/stats",
|
||||
response_model=AdaptiveStatsResponse,
|
||||
summary="获取Key的自适应统计",
|
||||
)
|
||||
async def get_adaptive_stats(
|
||||
key_id: str,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
) -> Any:
|
||||
"""
|
||||
获取指定Key的自适应 RPM 统计信息
|
||||
|
||||
包括:
|
||||
- 当前配置
|
||||
- 学习到的限制
|
||||
- 429错误统计
|
||||
- 调整历史
|
||||
"""
|
||||
adapter = GetAdaptiveStatsAdapter(key_id=key_id)
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/keys/{key_id}/learning",
|
||||
summary="Reset key's learning state",
|
||||
)
|
||||
async def reset_adaptive_learning(
|
||||
key_id: str,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
) -> Any:
|
||||
"""
|
||||
Reset the adaptive learning state for a specific key
|
||||
|
||||
Clears:
|
||||
- Learned RPM limit (learned_rpm_limit)
|
||||
- 429 error counts
|
||||
- Adjustment history
|
||||
|
||||
Does not change:
|
||||
- rpm_limit config (determines adaptive mode)
|
||||
"""
|
||||
adapter = ResetAdaptiveLearningAdapter(key_id=key_id)
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
|
||||
@router.patch(
|
||||
"/keys/{key_id}/limit",
|
||||
summary="Set key to fixed RPM limit mode",
|
||||
)
|
||||
async def set_rpm_limit(
|
||||
key_id: str,
|
||||
request: Request,
|
||||
limit: int = Query(..., ge=1, le=100, description="RPM limit value (1-100)"),
|
||||
db: Session = Depends(get_db),
|
||||
) -> Any:
|
||||
"""
|
||||
Set key to fixed RPM limit mode
|
||||
|
||||
Note:
|
||||
- After setting this value, key switches to fixed limit mode and won't auto-adjust
|
||||
- To restore adaptive mode, use PATCH /keys/{key_id}/mode
|
||||
"""
|
||||
adapter = SetRPMLimitAdapter(key_id=key_id, limit=limit)
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/summary",
|
||||
summary="获取自适应 RPM 的全局统计",
|
||||
)
|
||||
async def get_adaptive_summary(
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
) -> Any:
|
||||
"""
|
||||
获取自适应 RPM 的全局统计摘要
|
||||
|
||||
包括:
|
||||
- 启用自适应模式的Key数量
|
||||
- 总429错误数
|
||||
- RPM 限制调整次数
|
||||
"""
|
||||
adapter = AdaptiveSummaryAdapter()
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
|
||||
# ==================== Pipeline 适配器 ====================
|
||||
|
||||
|
||||
@dataclass
|
||||
class ListAdaptiveKeysAdapter(AdminApiAdapter):
|
||||
provider_id: str | None = None
|
||||
|
||||
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
||||
# 自适应模式:rpm_limit = NULL
|
||||
query = context.db.query(ProviderAPIKey).filter(ProviderAPIKey.rpm_limit.is_(None))
|
||||
if self.provider_id:
|
||||
query = query.filter(ProviderAPIKey.provider_id == self.provider_id)
|
||||
|
||||
keys = query.all()
|
||||
adaptive_manager = get_adaptive_rpm_manager()
|
||||
return [
|
||||
KeyListItem(
|
||||
id=key.id,
|
||||
name=key.name,
|
||||
provider_id=key.provider_id,
|
||||
api_formats=key.api_formats or [],
|
||||
is_adaptive=key.rpm_limit is None,
|
||||
rpm_limit=key.rpm_limit,
|
||||
effective_limit=adaptive_manager.get_effective_limit(key),
|
||||
learned_rpm_limit=key.learned_rpm_limit,
|
||||
concurrent_429_count=key.concurrent_429_count or 0,
|
||||
rpm_429_count=key.rpm_429_count or 0,
|
||||
)
|
||||
for key in keys
|
||||
]
|
||||
|
||||
|
||||
@dataclass
|
||||
class ToggleAdaptiveModeAdapter(AdminApiAdapter):
|
||||
key_id: str
|
||||
|
||||
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
||||
key = context.db.query(ProviderAPIKey).filter(ProviderAPIKey.id == self.key_id).first()
|
||||
if not key:
|
||||
raise HTTPException(status_code=404, detail="Key not found")
|
||||
|
||||
payload = context.ensure_json_body()
|
||||
try:
|
||||
body = EnableAdaptiveRequest.model_validate(payload)
|
||||
except ValidationError as e:
|
||||
errors = e.errors()
|
||||
if errors:
|
||||
raise InvalidRequestException(translate_pydantic_error(errors[0]))
|
||||
raise InvalidRequestException("请求数据验证失败")
|
||||
|
||||
if body.enabled:
|
||||
# 启用自适应模式:将 rpm_limit 设为 NULL
|
||||
key.rpm_limit = None
|
||||
message = "已切换为自适应模式,系统将自动学习并调整 RPM 限制"
|
||||
else:
|
||||
# 禁用自适应模式:设置固定限制
|
||||
if body.fixed_limit is None:
|
||||
raise HTTPException(
|
||||
status_code=400, detail="禁用自适应模式时必须提供 fixed_limit 参数"
|
||||
)
|
||||
key.rpm_limit = body.fixed_limit
|
||||
message = f"已切换为固定限制模式,RPM 限制设为 {body.fixed_limit}"
|
||||
|
||||
context.db.commit()
|
||||
context.db.refresh(key)
|
||||
|
||||
is_adaptive = key.rpm_limit is None
|
||||
adaptive_manager = get_adaptive_rpm_manager()
|
||||
return {
|
||||
"message": message,
|
||||
"key_id": key.id,
|
||||
"is_adaptive": is_adaptive,
|
||||
"rpm_limit": key.rpm_limit,
|
||||
"effective_limit": adaptive_manager.get_effective_limit(key),
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class GetAdaptiveStatsAdapter(AdminApiAdapter):
|
||||
key_id: str
|
||||
|
||||
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
||||
key = context.db.query(ProviderAPIKey).filter(ProviderAPIKey.id == self.key_id).first()
|
||||
if not key:
|
||||
raise HTTPException(status_code=404, detail="Key not found")
|
||||
|
||||
adaptive_manager = get_adaptive_rpm_manager()
|
||||
stats = adaptive_manager.get_adjustment_stats(key)
|
||||
|
||||
# 转换字段名以匹配响应模型
|
||||
return AdaptiveStatsResponse(
|
||||
adaptive_mode=stats["adaptive_mode"],
|
||||
rpm_limit=stats["rpm_limit"],
|
||||
effective_limit=stats["effective_limit"],
|
||||
learned_limit=stats["learned_limit"],
|
||||
concurrent_429_count=stats["concurrent_429_count"],
|
||||
rpm_429_count=stats["rpm_429_count"],
|
||||
last_429_at=stats["last_429_at"],
|
||||
last_429_type=stats["last_429_type"],
|
||||
adjustment_count=stats["adjustment_count"],
|
||||
recent_adjustments=stats["recent_adjustments"],
|
||||
learning_confidence=stats.get("learning_confidence"),
|
||||
enforcement_active=stats.get("enforcement_active"),
|
||||
observation_count=stats.get("observation_count", 0),
|
||||
header_observation_count=stats.get("header_observation_count", 0),
|
||||
latest_upstream_limit=stats.get("latest_upstream_limit"),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ResetAdaptiveLearningAdapter(AdminApiAdapter):
|
||||
key_id: str
|
||||
|
||||
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
||||
key = context.db.query(ProviderAPIKey).filter(ProviderAPIKey.id == self.key_id).first()
|
||||
if not key:
|
||||
raise HTTPException(status_code=404, detail="Key not found")
|
||||
|
||||
adaptive_manager = get_adaptive_rpm_manager()
|
||||
adaptive_manager.reset_learning(context.db, key)
|
||||
return {"message": "学习状态已重置", "key_id": key.id}
|
||||
|
||||
|
||||
@dataclass
|
||||
class SetRPMLimitAdapter(AdminApiAdapter):
|
||||
key_id: str
|
||||
limit: int
|
||||
|
||||
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
||||
key = context.db.query(ProviderAPIKey).filter(ProviderAPIKey.id == self.key_id).first()
|
||||
if not key:
|
||||
raise HTTPException(status_code=404, detail="Key not found")
|
||||
|
||||
was_adaptive = key.rpm_limit is None
|
||||
key.rpm_limit = self.limit
|
||||
context.db.commit()
|
||||
context.db.refresh(key)
|
||||
|
||||
return {
|
||||
"message": f"已设置为固定限制模式,RPM 限制为 {self.limit}",
|
||||
"key_id": key.id,
|
||||
"is_adaptive": False,
|
||||
"rpm_limit": key.rpm_limit,
|
||||
"previous_mode": "adaptive" if was_adaptive else "fixed",
|
||||
}
|
||||
|
||||
|
||||
class AdaptiveSummaryAdapter(AdminApiAdapter):
|
||||
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
||||
db = context.db
|
||||
is_adaptive = ProviderAPIKey.rpm_limit.is_(None)
|
||||
|
||||
# SQL 聚合获取 count / sum,避免全表 ORM 加载
|
||||
total_keys, total_concurrent_429, total_rpm_429 = (
|
||||
db.query(
|
||||
func.count(ProviderAPIKey.id),
|
||||
func.coalesce(func.sum(ProviderAPIKey.concurrent_429_count), 0),
|
||||
func.coalesce(func.sum(ProviderAPIKey.rpm_429_count), 0),
|
||||
)
|
||||
.filter(is_adaptive)
|
||||
.one()
|
||||
)
|
||||
|
||||
# adjustment_history 是 JSON 列,长度只能在 Python 侧统计;
|
||||
# 只加载有历史记录的 key 的必要列
|
||||
keys_with_history = (
|
||||
db.query(ProviderAPIKey)
|
||||
.options(
|
||||
load_only(ProviderAPIKey.id, ProviderAPIKey.name, ProviderAPIKey.adjustment_history)
|
||||
)
|
||||
.filter(is_adaptive, ProviderAPIKey.adjustment_history.isnot(None))
|
||||
.all()
|
||||
)
|
||||
|
||||
total_adjustments = sum(len(key.adjustment_history or []) for key in keys_with_history)
|
||||
|
||||
recent_adjustments = []
|
||||
for key in keys_with_history:
|
||||
if key.adjustment_history:
|
||||
for adj in key.adjustment_history[-3:]:
|
||||
recent_adjustments.append(
|
||||
{
|
||||
"key_id": key.id,
|
||||
"key_name": key.name,
|
||||
**adj,
|
||||
}
|
||||
)
|
||||
|
||||
recent_adjustments.sort(key=lambda item: item.get("timestamp", ""), reverse=True)
|
||||
|
||||
return {
|
||||
"total_adaptive_keys": total_keys,
|
||||
"total_concurrent_429_errors": int(total_concurrent_429),
|
||||
"total_rpm_429_errors": int(total_rpm_429),
|
||||
"total_adjustments": total_adjustments,
|
||||
"recent_adjustments": recent_adjustments[:10],
|
||||
}
|
||||
5
_deprecated_py_src/api/admin/api_keys/__init__.py
Normal file
5
_deprecated_py_src/api/admin/api_keys/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""API key admin routes export."""
|
||||
|
||||
from .routes import router
|
||||
|
||||
__all__ = ["router"]
|
||||
726
_deprecated_py_src/api/admin/api_keys/routes.py
Normal file
726
_deprecated_py_src/api/admin/api_keys/routes.py
Normal file
@@ -0,0 +1,726 @@
|
||||
"""管理员独立余额 API Key 管理路由。
|
||||
|
||||
独立余额Key:不关联用户配额,可配置独立余额限制或无限额度,用于给非注册用户使用。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any, Literal
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||
from fastapi.concurrency import run_in_threadpool
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from src.api.base.admin_adapter import AdminApiAdapter
|
||||
from src.api.base.context import ApiRequestContext
|
||||
from src.api.base.pipeline import get_pipeline
|
||||
from src.config import config
|
||||
from src.core.exceptions import InvalidRequestException, NotFoundException
|
||||
from src.core.logger import logger
|
||||
from src.database import get_db, get_db_context
|
||||
from src.models.api import CreateApiKeyRequest
|
||||
from src.models.database import ApiKey, Usage, Wallet
|
||||
from src.services.user.apikey import ApiKeyService
|
||||
from src.services.user.bulk_cleanup import pre_clean_api_key
|
||||
from src.services.wallet import WalletService
|
||||
|
||||
APP_TIMEZONE = ZoneInfo(config.app_timezone)
|
||||
|
||||
|
||||
def parse_expiry_date(date_str: str | None) -> datetime | None:
|
||||
"""解析过期日期字符串为 datetime 对象。
|
||||
|
||||
Args:
|
||||
date_str: 日期字符串,支持 "YYYY-MM-DD" 或 ISO 格式
|
||||
|
||||
Returns:
|
||||
datetime 对象(当天 23:59:59.999999,应用时区),或 None 如果输入为空
|
||||
|
||||
Raises:
|
||||
BadRequestException: 日期格式无效
|
||||
"""
|
||||
if not date_str or not date_str.strip():
|
||||
return None
|
||||
|
||||
date_str = date_str.strip()
|
||||
|
||||
# 尝试 YYYY-MM-DD 格式
|
||||
try:
|
||||
parsed_date = datetime.strptime(date_str, "%Y-%m-%d")
|
||||
# 设置为当天结束时间 (23:59:59.999999,应用时区)
|
||||
return parsed_date.replace(
|
||||
hour=23, minute=59, second=59, microsecond=999999, tzinfo=APP_TIMEZONE
|
||||
)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# 尝试完整 ISO 格式
|
||||
try:
|
||||
return datetime.fromisoformat(date_str.replace("Z", "+00:00"))
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
raise InvalidRequestException(f"无效的日期格式: {date_str},请使用 YYYY-MM-DD 格式")
|
||||
|
||||
|
||||
router = APIRouter(prefix="/api/admin/api-keys", tags=["Admin - API Keys (Standalone)"])
|
||||
pipeline = get_pipeline()
|
||||
|
||||
|
||||
def _serialize_standalone_key_item(
|
||||
api_key: ApiKey, *, total_tokens: int | None = None
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"id": api_key.id,
|
||||
"user_id": api_key.user_id,
|
||||
"name": api_key.name,
|
||||
"key_display": api_key.get_display_key(),
|
||||
"is_active": api_key.is_active,
|
||||
"is_standalone": api_key.is_standalone,
|
||||
"total_requests": api_key.total_requests,
|
||||
"total_tokens": int(total_tokens or 0),
|
||||
"total_cost_usd": float(api_key.total_cost_usd or 0),
|
||||
"rate_limit": api_key.rate_limit,
|
||||
"allowed_providers": api_key.allowed_providers,
|
||||
"allowed_api_formats": api_key.allowed_api_formats,
|
||||
"allowed_models": api_key.allowed_models,
|
||||
"last_used_at": api_key.last_used_at.isoformat() if api_key.last_used_at else None,
|
||||
"expires_at": api_key.expires_at.isoformat() if api_key.expires_at else None,
|
||||
"created_at": api_key.created_at.isoformat(),
|
||||
"updated_at": api_key.updated_at.isoformat() if api_key.updated_at else None,
|
||||
"auto_delete_on_expiry": api_key.auto_delete_on_expiry,
|
||||
}
|
||||
|
||||
|
||||
def _list_standalone_api_keys_sync(
|
||||
skip: int,
|
||||
limit: int,
|
||||
is_active: bool | None,
|
||||
) -> dict[str, Any]:
|
||||
with get_db_context() as db:
|
||||
query = db.query(ApiKey).filter(ApiKey.is_standalone == True)
|
||||
if is_active is not None:
|
||||
query = query.filter(ApiKey.is_active == is_active)
|
||||
|
||||
total = int(query.with_entities(func.count(ApiKey.id)).scalar() or 0)
|
||||
api_keys = query.order_by(ApiKey.created_at.desc()).offset(skip).limit(limit).all()
|
||||
|
||||
wallet_initialized = False
|
||||
for api_key in api_keys:
|
||||
wallet = WalletService.get_wallet(db, api_key_id=api_key.id)
|
||||
if wallet is None:
|
||||
_ensure_standalone_wallet(db, api_key)
|
||||
wallet_initialized = True
|
||||
if wallet_initialized:
|
||||
db.commit()
|
||||
for api_key in api_keys:
|
||||
db.refresh(api_key)
|
||||
|
||||
token_map: dict[str, int] = {}
|
||||
if api_keys:
|
||||
stats_rows = (
|
||||
db.query(
|
||||
Usage.api_key_id,
|
||||
func.sum(Usage.total_tokens).label("total_tokens"),
|
||||
)
|
||||
.filter(Usage.api_key_id.in_([api_key.id for api_key in api_keys]))
|
||||
.group_by(Usage.api_key_id)
|
||||
.all()
|
||||
)
|
||||
token_map = {row.api_key_id: int(row.total_tokens or 0) for row in stats_rows}
|
||||
|
||||
return {
|
||||
"api_keys": [
|
||||
_serialize_standalone_key_item(
|
||||
api_key,
|
||||
total_tokens=token_map.get(api_key.id, 0),
|
||||
)
|
||||
for api_key in api_keys
|
||||
],
|
||||
"total": total,
|
||||
"limit": limit,
|
||||
"skip": skip,
|
||||
}
|
||||
|
||||
|
||||
def _create_standalone_api_key_sync(
|
||||
admin_user_id: str,
|
||||
key_data: CreateApiKeyRequest,
|
||||
) -> tuple[dict[str, Any], dict[str, Any]]:
|
||||
with get_db_context() as db:
|
||||
if key_data.initial_balance_usd is not None and key_data.initial_balance_usd <= 0:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="创建独立余额Key时,初始余额必须大于 0(或设置为 null 表示无限制)",
|
||||
)
|
||||
|
||||
expires_at_dt = parse_expiry_date(key_data.expires_at)
|
||||
api_key, plain_key = ApiKeyService.create_api_key(
|
||||
db=db,
|
||||
user_id=admin_user_id,
|
||||
name=key_data.name,
|
||||
allowed_providers=key_data.allowed_providers,
|
||||
allowed_api_formats=key_data.allowed_api_formats,
|
||||
allowed_models=key_data.allowed_models,
|
||||
rate_limit=key_data.rate_limit,
|
||||
expire_days=key_data.expire_days,
|
||||
expires_at=expires_at_dt,
|
||||
is_standalone=True,
|
||||
auto_delete_on_expiry=key_data.auto_delete_on_expiry,
|
||||
)
|
||||
|
||||
wallet = WalletService.initialize_api_key_wallet(
|
||||
db,
|
||||
api_key=api_key,
|
||||
initial_balance_usd=key_data.initial_balance_usd,
|
||||
unlimited=key_data.initial_balance_usd is None,
|
||||
operator_id=admin_user_id,
|
||||
description="独立密钥初始调账",
|
||||
)
|
||||
if wallet is None:
|
||||
raise InvalidRequestException("独立密钥钱包初始化失败")
|
||||
|
||||
db.commit()
|
||||
db.refresh(api_key)
|
||||
wallet_summary = WalletService.serialize_wallet_summary(wallet)
|
||||
return (
|
||||
{
|
||||
"id": api_key.id,
|
||||
"key": plain_key,
|
||||
"name": api_key.name,
|
||||
"key_display": api_key.get_display_key(),
|
||||
"is_standalone": True,
|
||||
"rate_limit": api_key.rate_limit,
|
||||
"expires_at": api_key.expires_at.isoformat() if api_key.expires_at else None,
|
||||
"created_at": api_key.created_at.isoformat(),
|
||||
"wallet": wallet_summary,
|
||||
"message": "独立余额Key创建成功,请妥善保存完整密钥,后续将无法查看",
|
||||
},
|
||||
{
|
||||
"action": "create_standalone_api_key",
|
||||
"key_id": api_key.id,
|
||||
"initial_balance_usd": key_data.initial_balance_usd,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _update_standalone_api_key_sync(
|
||||
key_id: str,
|
||||
key_data: CreateApiKeyRequest,
|
||||
) -> tuple[dict[str, Any], list[str]]:
|
||||
with get_db_context() as db:
|
||||
api_key = db.query(ApiKey).filter(ApiKey.id == key_id).first()
|
||||
if not api_key:
|
||||
raise NotFoundException("API密钥不存在", "api_key")
|
||||
if not api_key.is_standalone:
|
||||
raise InvalidRequestException("仅支持更新独立密钥")
|
||||
|
||||
update_data: dict[str, Any] = {}
|
||||
if key_data.name is not None:
|
||||
update_data["name"] = key_data.name
|
||||
if "rate_limit" in key_data.model_fields_set:
|
||||
update_data["rate_limit"] = key_data.rate_limit
|
||||
if (
|
||||
hasattr(key_data, "auto_delete_on_expiry")
|
||||
and key_data.auto_delete_on_expiry is not None
|
||||
):
|
||||
update_data["auto_delete_on_expiry"] = key_data.auto_delete_on_expiry
|
||||
if hasattr(key_data, "allowed_providers"):
|
||||
update_data["allowed_providers"] = key_data.allowed_providers
|
||||
if hasattr(key_data, "allowed_api_formats"):
|
||||
update_data["allowed_api_formats"] = key_data.allowed_api_formats
|
||||
if hasattr(key_data, "allowed_models"):
|
||||
update_data["allowed_models"] = key_data.allowed_models
|
||||
|
||||
if key_data.expires_at and key_data.expires_at.strip():
|
||||
update_data["expires_at"] = parse_expiry_date(key_data.expires_at)
|
||||
elif "expires_at" in key_data.model_fields_set:
|
||||
update_data["expires_at"] = None
|
||||
elif "expire_days" in key_data.model_fields_set:
|
||||
if key_data.expire_days is not None and key_data.expire_days > 0:
|
||||
update_data["expires_at"] = datetime.now(timezone.utc) + timedelta(
|
||||
days=key_data.expire_days
|
||||
)
|
||||
else:
|
||||
update_data["expires_at"] = None
|
||||
|
||||
changed_fields = list(update_data.keys())
|
||||
|
||||
if "initial_balance_usd" in key_data.model_fields_set:
|
||||
raise InvalidRequestException("编辑独立密钥不支持修改余额,请使用钱包操作")
|
||||
|
||||
if (
|
||||
"unlimited_balance" in key_data.model_fields_set
|
||||
and key_data.unlimited_balance is not None
|
||||
):
|
||||
wallet = _ensure_standalone_wallet(db, api_key)
|
||||
desired_mode: Literal["finite", "unlimited"] = (
|
||||
"unlimited" if key_data.unlimited_balance else "finite"
|
||||
)
|
||||
if wallet.limit_mode != desired_mode:
|
||||
WalletService.set_wallet_limit_mode(db, wallet=wallet, limit_mode=desired_mode)
|
||||
changed_fields.append("unlimited_balance")
|
||||
|
||||
updated_key = ApiKeyService.update_api_key(db, key_id, **update_data)
|
||||
if not updated_key:
|
||||
raise NotFoundException("更新失败", "api_key")
|
||||
|
||||
wallet = _ensure_standalone_wallet(db, updated_key)
|
||||
wallet_summary = WalletService.serialize_wallet_summary(wallet)
|
||||
return (
|
||||
{
|
||||
"id": updated_key.id,
|
||||
"name": updated_key.name,
|
||||
"key_display": updated_key.get_display_key(),
|
||||
"is_active": updated_key.is_active,
|
||||
"rate_limit": updated_key.rate_limit,
|
||||
"expires_at": (
|
||||
updated_key.expires_at.isoformat() if updated_key.expires_at else None
|
||||
),
|
||||
"updated_at": (
|
||||
updated_key.updated_at.isoformat() if updated_key.updated_at else None
|
||||
),
|
||||
"wallet": wallet_summary,
|
||||
"message": "API密钥已更新",
|
||||
},
|
||||
changed_fields,
|
||||
)
|
||||
|
||||
|
||||
def _toggle_standalone_api_key_sync(key_id: str) -> tuple[dict[str, Any], dict[str, Any]]:
|
||||
with get_db_context() as db:
|
||||
api_key = db.query(ApiKey).filter(ApiKey.id == key_id).first()
|
||||
if not api_key:
|
||||
raise NotFoundException("API密钥不存在", "api_key")
|
||||
if not api_key.is_standalone:
|
||||
raise InvalidRequestException("仅支持操作独立密钥")
|
||||
|
||||
api_key.is_active = not api_key.is_active
|
||||
api_key.updated_at = datetime.now(timezone.utc)
|
||||
db.commit()
|
||||
db.refresh(api_key)
|
||||
return (
|
||||
{
|
||||
"id": api_key.id,
|
||||
"is_active": api_key.is_active,
|
||||
"message": f"API密钥已{'启用' if api_key.is_active else '禁用'}",
|
||||
},
|
||||
{
|
||||
"action": "toggle_api_key",
|
||||
"target_key_id": api_key.id,
|
||||
"user_id": api_key.user_id,
|
||||
"new_status": "enabled" if api_key.is_active else "disabled",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _delete_standalone_api_key_sync(
|
||||
key_id: str,
|
||||
) -> tuple[dict[str, str], dict[str, Any], str | None]:
|
||||
with get_db_context() as db:
|
||||
api_key = db.query(ApiKey).filter(ApiKey.id == key_id).first()
|
||||
if not api_key:
|
||||
raise HTTPException(status_code=404, detail="API密钥不存在")
|
||||
if not api_key.is_standalone:
|
||||
raise InvalidRequestException("仅支持删除独立密钥")
|
||||
|
||||
user = api_key.user
|
||||
pre_clean_api_key(db, api_key.id)
|
||||
db.delete(api_key)
|
||||
return (
|
||||
{"message": "API密钥已删除"},
|
||||
{
|
||||
"action": "delete_api_key",
|
||||
"target_key_id": key_id,
|
||||
"user_id": user.id if user else None,
|
||||
"user_email": user.email if user else None,
|
||||
},
|
||||
user.email if user else None,
|
||||
)
|
||||
|
||||
|
||||
def _ensure_standalone_wallet(
|
||||
db: Session,
|
||||
api_key: ApiKey,
|
||||
*,
|
||||
limit_mode: Literal["finite", "unlimited"] | None = None,
|
||||
) -> Wallet:
|
||||
"""确保独立 Key 已绑定钱包,并可选同步额度模式。"""
|
||||
wallet = WalletService.get_or_create_wallet(db, api_key=api_key)
|
||||
if wallet is None:
|
||||
raise InvalidRequestException("独立密钥钱包初始化失败")
|
||||
|
||||
if limit_mode is not None and wallet.limit_mode != limit_mode:
|
||||
wallet = WalletService.set_wallet_limit_mode(db, wallet=wallet, limit_mode=limit_mode)
|
||||
|
||||
return wallet
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_standalone_api_keys(
|
||||
request: Request,
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(100, ge=1, le=500),
|
||||
is_active: bool | None = None,
|
||||
db: Session = Depends(get_db),
|
||||
) -> Any:
|
||||
"""
|
||||
列出所有独立余额 API Keys
|
||||
|
||||
获取系统中所有独立余额 API Key 的列表。独立余额 Key 不关联用户配额,
|
||||
有独立的余额限制,主要用于给非注册用户使用。
|
||||
|
||||
**查询参数**:
|
||||
- `skip`: 跳过的记录数(分页偏移量),默认 0
|
||||
- `limit`: 返回的记录数(分页限制),默认 100,最大 500
|
||||
- `is_active`: 可选,根据启用状态筛选(true/false)
|
||||
|
||||
**返回字段**:
|
||||
- `api_keys`: API Key 列表,包含 id, name, key_display, is_active, is_standalone,
|
||||
total_requests, total_cost_usd, rate_limit, allowed_providers, allowed_api_formats,
|
||||
allowed_models, last_used_at, expires_at, created_at, updated_at, auto_delete_on_expiry 等字段
|
||||
- `total`: 符合条件的总记录数
|
||||
- `limit`: 当前分页限制
|
||||
- `skip`: 当前分页偏移量
|
||||
"""
|
||||
adapter = AdminListStandaloneKeysAdapter(skip=skip, limit=limit, is_active=is_active)
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
|
||||
@router.post("")
|
||||
async def create_standalone_api_key(
|
||||
request: Request,
|
||||
key_data: CreateApiKeyRequest,
|
||||
db: Session = Depends(get_db),
|
||||
) -> Any:
|
||||
"""
|
||||
创建独立余额 API Key
|
||||
|
||||
创建一个新的独立余额 API Key。独立余额 Key 可设置初始余额,或使用无限额度。
|
||||
|
||||
**请求体字段**:
|
||||
- `name`: API Key 的名称
|
||||
- `initial_balance_usd`: 可选,初始余额(美元),null 表示无限制额度
|
||||
- `allowed_providers`: 可选,允许使用的提供商列表
|
||||
- `allowed_api_formats`: 可选,允许使用的 API 格式列表
|
||||
- `allowed_models`: 可选,允许使用的模型列表
|
||||
- `rate_limit`: 可选,每分钟请求限制(null 表示跟随系统默认,0 表示不限制)
|
||||
- `expire_days`: 可选,过期天数(与 expires_at 二选一)
|
||||
- `expires_at`: 可选,过期时间(ISO 格式或 YYYY-MM-DD 格式,优先级高于 expire_days)
|
||||
- `auto_delete_on_expiry`: 可选,过期后是否自动删除
|
||||
|
||||
**返回字段**:
|
||||
- `id`: API Key ID
|
||||
- `key`: 完整的 API Key(仅在创建时返回一次)
|
||||
- `name`: API Key 名称
|
||||
- `key_display`: 脱敏显示的 Key
|
||||
- `is_standalone`: 是否为独立余额 Key(始终为 true)
|
||||
- `wallet`: 钱包摘要(总余额、充值余额、赠款余额、额度模式等)
|
||||
- `rate_limit`: 速率限制配置
|
||||
- `expires_at`: 过期时间
|
||||
- `created_at`: 创建时间
|
||||
- `message`: 提示信息
|
||||
"""
|
||||
adapter = AdminCreateStandaloneKeyAdapter(key_data=key_data)
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
|
||||
@router.put("/{key_id}")
|
||||
async def update_api_key(
|
||||
key_id: str, request: Request, key_data: CreateApiKeyRequest, db: Session = Depends(get_db)
|
||||
) -> Any:
|
||||
"""
|
||||
更新独立余额 API Key
|
||||
|
||||
更新指定 ID 的独立余额 API Key 的配置信息。
|
||||
|
||||
**路径参数**:
|
||||
- `key_id`: API Key ID
|
||||
|
||||
**请求体字段**:
|
||||
- `name`: 可选,API Key 的名称
|
||||
- `unlimited_balance`: 可选,是否无限余额(true=无限,false=有限,不修改余额数值)
|
||||
- `rate_limit`: 可选,每分钟请求限制(null 表示跟随系统默认,0 表示不限制)
|
||||
- `allowed_providers`: 可选,允许使用的提供商列表
|
||||
- `allowed_api_formats`: 可选,允许使用的 API 格式列表
|
||||
- `allowed_models`: 可选,允许使用的模型列表
|
||||
- `expire_days`: 可选,过期天数(与 expires_at 二选一)
|
||||
- `expires_at`: 可选,过期时间(ISO 格式或 YYYY-MM-DD 格式,优先级高于 expire_days,null 或空字符串表示永不过期)
|
||||
- `auto_delete_on_expiry`: 可选,过期后是否自动删除
|
||||
|
||||
**返回字段**:
|
||||
- `id`: API Key ID
|
||||
- `name`: API Key 名称
|
||||
- `key_display`: 脱敏显示的 Key
|
||||
- `is_active`: 是否启用
|
||||
- `wallet`: 钱包摘要(总余额、充值余额、赠款余额、额度模式等)
|
||||
- `rate_limit`: 速率限制配置
|
||||
- `expires_at`: 过期时间
|
||||
- `updated_at`: 更新时间
|
||||
- `message`: 提示信息
|
||||
"""
|
||||
adapter = AdminUpdateApiKeyAdapter(key_id=key_id, key_data=key_data)
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
|
||||
@router.patch("/{key_id}")
|
||||
async def toggle_api_key(key_id: str, request: Request, db: Session = Depends(get_db)) -> Any:
|
||||
"""
|
||||
切换 API Key 启用状态
|
||||
|
||||
切换指定 API Key 的启用/禁用状态。
|
||||
|
||||
**路径参数**:
|
||||
- `key_id`: API Key ID
|
||||
|
||||
**返回字段**:
|
||||
- `id`: API Key ID
|
||||
- `is_active`: 新的启用状态
|
||||
- `message`: 提示信息
|
||||
"""
|
||||
adapter = AdminToggleApiKeyAdapter(key_id=key_id)
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
|
||||
@router.delete("/{key_id}")
|
||||
async def delete_api_key(key_id: str, request: Request, db: Session = Depends(get_db)) -> None:
|
||||
"""
|
||||
删除 API Key
|
||||
|
||||
删除指定的 API Key。此操作不可逆。
|
||||
|
||||
**路径参数**:
|
||||
- `key_id`: API Key ID
|
||||
|
||||
**返回字段**:
|
||||
- `message`: 提示信息
|
||||
"""
|
||||
adapter = AdminDeleteApiKeyAdapter(key_id=key_id)
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
|
||||
@router.get("/{key_id}")
|
||||
async def get_api_key_detail(
|
||||
key_id: str,
|
||||
request: Request,
|
||||
include_key: bool = Query(False, description="Include full decrypted key in response"),
|
||||
db: Session = Depends(get_db),
|
||||
) -> Any:
|
||||
"""
|
||||
获取 API Key 详情
|
||||
|
||||
获取指定 API Key 的详细信息。可选择是否返回完整的解密密钥。
|
||||
|
||||
**路径参数**:
|
||||
- `key_id`: API Key ID
|
||||
|
||||
**查询参数**:
|
||||
- `include_key`: 是否包含完整的解密密钥,默认 false
|
||||
|
||||
**返回字段**:
|
||||
- 当 include_key=false 时,返回基本信息:id, user_id, name, key_display, is_active,
|
||||
is_standalone, total_requests, total_cost_usd, rate_limit, allowed_providers,
|
||||
allowed_api_formats, allowed_models, last_used_at, expires_at, created_at, updated_at,
|
||||
wallet
|
||||
- 当 include_key=true 时,返回完整密钥:key
|
||||
"""
|
||||
if include_key:
|
||||
adapter = AdminGetFullKeyAdapter(key_id=key_id)
|
||||
else:
|
||||
# Return basic key info without full key
|
||||
adapter = AdminGetKeyDetailAdapter(key_id=key_id)
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
|
||||
class AdminListStandaloneKeysAdapter(AdminApiAdapter):
|
||||
"""列出独立余额Keys"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
skip: int,
|
||||
limit: int,
|
||||
is_active: bool | None,
|
||||
):
|
||||
self.skip = skip
|
||||
self.limit = limit
|
||||
self.is_active = is_active
|
||||
|
||||
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
||||
result = await run_in_threadpool(
|
||||
_list_standalone_api_keys_sync,
|
||||
self.skip,
|
||||
self.limit,
|
||||
self.is_active,
|
||||
)
|
||||
context.add_audit_metadata(
|
||||
action="list_standalone_api_keys",
|
||||
filter_is_active=self.is_active,
|
||||
limit=self.limit,
|
||||
skip=self.skip,
|
||||
total=result["total"],
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
class AdminCreateStandaloneKeyAdapter(AdminApiAdapter):
|
||||
"""创建独立余额Key"""
|
||||
|
||||
def __init__(self, key_data: CreateApiKeyRequest):
|
||||
self.key_data = key_data
|
||||
|
||||
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
||||
result, audit_meta = await run_in_threadpool(
|
||||
_create_standalone_api_key_sync,
|
||||
context.user.id,
|
||||
self.key_data,
|
||||
)
|
||||
logger.info(
|
||||
f"管理员创建独立余额Key: ID {result['id']}, 初始余额 ${self.key_data.initial_balance_usd}"
|
||||
)
|
||||
context.add_audit_metadata(**audit_meta)
|
||||
return result
|
||||
|
||||
|
||||
class AdminUpdateApiKeyAdapter(AdminApiAdapter):
|
||||
"""更新独立余额Key"""
|
||||
|
||||
def __init__(self, key_id: str, key_data: CreateApiKeyRequest):
|
||||
self.key_id = key_id
|
||||
self.key_data = key_data
|
||||
|
||||
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
||||
result, changed_fields = await run_in_threadpool(
|
||||
_update_standalone_api_key_sync,
|
||||
self.key_id,
|
||||
self.key_data,
|
||||
)
|
||||
logger.info(f"管理员更新独立余额Key: ID {self.key_id}, 更新字段 {changed_fields}")
|
||||
context.add_audit_metadata(
|
||||
action="update_standalone_api_key",
|
||||
key_id=self.key_id,
|
||||
updated_fields=changed_fields,
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
class AdminToggleApiKeyAdapter(AdminApiAdapter):
|
||||
def __init__(self, key_id: str):
|
||||
self.key_id = key_id
|
||||
|
||||
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
||||
result, audit_meta = await run_in_threadpool(_toggle_standalone_api_key_sync, self.key_id)
|
||||
logger.info(
|
||||
f"管理员切换API密钥状态: Key ID {self.key_id}, 新状态 {'启用' if result['is_active'] else '禁用'}"
|
||||
)
|
||||
context.add_audit_metadata(**audit_meta)
|
||||
return result
|
||||
|
||||
|
||||
class AdminDeleteApiKeyAdapter(AdminApiAdapter):
|
||||
def __init__(self, key_id: str):
|
||||
self.key_id = key_id
|
||||
|
||||
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
||||
result, audit_meta, user_email = await run_in_threadpool(
|
||||
_delete_standalone_api_key_sync,
|
||||
self.key_id,
|
||||
)
|
||||
logger.info(f"管理员删除API密钥: Key ID {self.key_id}, 用户 {user_email or '未知'}")
|
||||
context.add_audit_metadata(**audit_meta)
|
||||
return result
|
||||
|
||||
|
||||
class AdminGetFullKeyAdapter(AdminApiAdapter):
|
||||
"""获取完整的API密钥"""
|
||||
|
||||
def __init__(self, key_id: str):
|
||||
self.key_id = key_id
|
||||
|
||||
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
||||
from src.core.crypto import crypto_service
|
||||
|
||||
db = context.db
|
||||
|
||||
# 查找API密钥
|
||||
api_key = db.query(ApiKey).filter(ApiKey.id == self.key_id).first()
|
||||
if not api_key:
|
||||
raise NotFoundException("API密钥不存在", "api_key")
|
||||
if not api_key.is_standalone:
|
||||
raise InvalidRequestException("仅支持查看独立密钥")
|
||||
|
||||
# 解密完整密钥
|
||||
if not api_key.key_encrypted:
|
||||
raise HTTPException(status_code=400, detail="该密钥没有存储完整密钥信息")
|
||||
|
||||
try:
|
||||
full_key = crypto_service.decrypt(api_key.key_encrypted)
|
||||
except Exception as e:
|
||||
logger.error(f"解密API密钥失败: Key ID {self.key_id}, 错误: {e}")
|
||||
raise HTTPException(status_code=500, detail="解密密钥失败")
|
||||
|
||||
logger.info(f"管理员查看完整API密钥: Key ID {self.key_id}")
|
||||
|
||||
context.add_audit_metadata(
|
||||
action="view_full_api_key",
|
||||
key_id=self.key_id,
|
||||
key_name=api_key.name,
|
||||
)
|
||||
|
||||
return {
|
||||
"key": full_key,
|
||||
}
|
||||
|
||||
|
||||
class AdminGetKeyDetailAdapter(AdminApiAdapter):
|
||||
"""Get API key detail without full key"""
|
||||
|
||||
def __init__(self, key_id: str):
|
||||
self.key_id = key_id
|
||||
|
||||
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
||||
db = context.db
|
||||
|
||||
api_key = db.query(ApiKey).filter(ApiKey.id == self.key_id).first()
|
||||
if not api_key:
|
||||
raise NotFoundException("API密钥不存在", "api_key")
|
||||
if not api_key.is_standalone:
|
||||
raise InvalidRequestException("仅支持查看独立密钥")
|
||||
|
||||
wallet = WalletService.get_wallet(db, api_key_id=api_key.id)
|
||||
wallet_summary = WalletService.serialize_wallet_summary(wallet)
|
||||
|
||||
context.add_audit_metadata(
|
||||
action="get_api_key_detail",
|
||||
key_id=self.key_id,
|
||||
)
|
||||
|
||||
return {
|
||||
"id": api_key.id,
|
||||
"user_id": api_key.user_id,
|
||||
"name": api_key.name,
|
||||
"key_display": api_key.get_display_key(),
|
||||
"is_active": api_key.is_active,
|
||||
"is_standalone": api_key.is_standalone,
|
||||
"total_requests": api_key.total_requests,
|
||||
"total_tokens": int(
|
||||
(
|
||||
db.query(func.sum(Usage.total_tokens))
|
||||
.filter(Usage.api_key_id == api_key.id)
|
||||
.scalar()
|
||||
)
|
||||
or 0
|
||||
),
|
||||
"total_cost_usd": float(api_key.total_cost_usd or 0),
|
||||
"rate_limit": api_key.rate_limit,
|
||||
"allowed_providers": api_key.allowed_providers,
|
||||
"allowed_api_formats": api_key.allowed_api_formats,
|
||||
"allowed_models": api_key.allowed_models,
|
||||
"last_used_at": api_key.last_used_at.isoformat() if api_key.last_used_at else None,
|
||||
"expires_at": api_key.expires_at.isoformat() if api_key.expires_at else None,
|
||||
"created_at": api_key.created_at.isoformat(),
|
||||
"updated_at": api_key.updated_at.isoformat() if api_key.updated_at else None,
|
||||
"wallet": wallet_summary,
|
||||
}
|
||||
5
_deprecated_py_src/api/admin/billing/__init__.py
Normal file
5
_deprecated_py_src/api/admin/billing/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""Billing 配置管理 API 模块(billing_rules / dimension_collectors)。"""
|
||||
|
||||
from .routes import router
|
||||
|
||||
__all__ = ["router"]
|
||||
588
_deprecated_py_src/api/admin/billing/routes.py
Normal file
588
_deprecated_py_src/api/admin/billing/routes.py
Normal file
@@ -0,0 +1,588 @@
|
||||
"""Billing 配置管理 API 路由。
|
||||
|
||||
包含:
|
||||
- billing_rules: 计费规则(公式/变量/维度映射)
|
||||
- dimension_collectors: 维度采集器(request/response/metadata/computed)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import Any, Literal
|
||||
|
||||
from fastapi import APIRouter, Depends, Query, Request
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from src.api.base.admin_adapter import AdminApiAdapter
|
||||
from src.api.base.context import ApiRequestContext
|
||||
from src.api.base.pipeline import get_pipeline
|
||||
from src.core.exceptions import InvalidRequestException, NotFoundException
|
||||
from src.database import get_db
|
||||
from src.models.database import BillingRule, DimensionCollector
|
||||
from src.services.billing.formula_engine import SafeExpressionEvaluator, UnsafeExpressionError
|
||||
from src.services.billing.presets import BillingPresetService, PresetApplyMode, list_preset_packs
|
||||
|
||||
router = APIRouter(prefix="/api/admin/billing", tags=["Admin - Billing"])
|
||||
pipeline = get_pipeline()
|
||||
_expr_validator = SafeExpressionEvaluator()
|
||||
|
||||
|
||||
AllowedTaskType = Literal["chat", "video", "image", "audio"]
|
||||
AllowedCollectorSourceType = Literal["request", "response", "metadata", "computed"]
|
||||
AllowedValueType = Literal["float", "int", "string"]
|
||||
|
||||
|
||||
class BillingRuleUpsertRequest(BaseModel):
|
||||
name: str = Field(..., min_length=1, max_length=100)
|
||||
task_type: AllowedTaskType = "chat"
|
||||
|
||||
global_model_id: str | None = None
|
||||
model_id: str | None = None
|
||||
|
||||
expression: str = Field(..., min_length=1)
|
||||
variables: dict[str, Any] = Field(default_factory=dict)
|
||||
dimension_mappings: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
is_enabled: bool = True
|
||||
|
||||
|
||||
class BillingRuleResponse(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
task_type: str
|
||||
global_model_id: str | None
|
||||
model_id: str | None
|
||||
expression: str
|
||||
variables: dict[str, Any]
|
||||
dimension_mappings: dict[str, Any]
|
||||
is_enabled: bool
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
@classmethod
|
||||
def from_orm_obj(cls, rule: BillingRule) -> "BillingRuleResponse":
|
||||
return cls(
|
||||
id=rule.id,
|
||||
name=rule.name,
|
||||
task_type=rule.task_type,
|
||||
global_model_id=rule.global_model_id,
|
||||
model_id=rule.model_id,
|
||||
expression=rule.expression,
|
||||
variables=rule.variables or {},
|
||||
dimension_mappings=rule.dimension_mappings or {},
|
||||
is_enabled=bool(rule.is_enabled),
|
||||
created_at=rule.created_at,
|
||||
updated_at=rule.updated_at,
|
||||
)
|
||||
|
||||
|
||||
class DimensionCollectorUpsertRequest(BaseModel):
|
||||
api_format: str = Field(..., min_length=1, max_length=50)
|
||||
task_type: str = Field(..., min_length=1, max_length=20)
|
||||
dimension_name: str = Field(..., min_length=1, max_length=100)
|
||||
|
||||
source_type: AllowedCollectorSourceType
|
||||
source_path: str | None = None
|
||||
value_type: AllowedValueType = "float"
|
||||
transform_expression: str | None = None
|
||||
default_value: str | None = None
|
||||
|
||||
priority: int = 0
|
||||
is_enabled: bool = True
|
||||
|
||||
|
||||
class DimensionCollectorResponse(BaseModel):
|
||||
id: str
|
||||
api_format: str
|
||||
task_type: str
|
||||
dimension_name: str
|
||||
source_type: str
|
||||
source_path: str | None
|
||||
value_type: str
|
||||
transform_expression: str | None
|
||||
default_value: str | None
|
||||
priority: int
|
||||
is_enabled: bool
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
@classmethod
|
||||
def from_orm_obj(cls, c: DimensionCollector) -> "DimensionCollectorResponse":
|
||||
return cls(
|
||||
id=c.id,
|
||||
api_format=c.api_format,
|
||||
task_type=c.task_type,
|
||||
dimension_name=c.dimension_name,
|
||||
source_type=c.source_type,
|
||||
source_path=c.source_path,
|
||||
value_type=c.value_type,
|
||||
transform_expression=c.transform_expression,
|
||||
default_value=c.default_value,
|
||||
priority=int(c.priority or 0),
|
||||
is_enabled=bool(c.is_enabled),
|
||||
created_at=c.created_at,
|
||||
updated_at=c.updated_at,
|
||||
)
|
||||
|
||||
|
||||
class BillingPresetInfoResponse(BaseModel):
|
||||
name: str
|
||||
version: str
|
||||
description: str
|
||||
collector_count: int
|
||||
|
||||
|
||||
class ApplyBillingPresetRequest(BaseModel):
|
||||
preset: str = Field(..., min_length=1, max_length=100)
|
||||
mode: PresetApplyMode = "merge"
|
||||
|
||||
|
||||
@router.get("/presets")
|
||||
async def list_billing_presets(request: Request, db: Session = Depends(get_db)) -> Any:
|
||||
adapter = BillingPresetListAdapter()
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
|
||||
@router.post("/presets/apply")
|
||||
async def apply_billing_preset(request: Request, db: Session = Depends(get_db)) -> Any:
|
||||
adapter = BillingPresetApplyAdapter()
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
|
||||
@router.get("/rules")
|
||||
async def list_billing_rules(
|
||||
request: Request,
|
||||
task_type: str | None = Query(None),
|
||||
is_enabled: bool | None = Query(None),
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(50, ge=1, le=200),
|
||||
db: Session = Depends(get_db),
|
||||
) -> Any:
|
||||
adapter = BillingRuleListAdapter(
|
||||
task_type=task_type,
|
||||
is_enabled=is_enabled,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
)
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
|
||||
@router.get("/rules/{rule_id}")
|
||||
async def get_billing_rule(rule_id: str, request: Request, db: Session = Depends(get_db)) -> Any:
|
||||
adapter = BillingRuleDetailAdapter(rule_id=rule_id)
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
|
||||
@router.post("/rules")
|
||||
async def create_billing_rule(request: Request, db: Session = Depends(get_db)) -> Any:
|
||||
adapter = BillingRuleCreateAdapter()
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
|
||||
@router.put("/rules/{rule_id}")
|
||||
async def update_billing_rule(rule_id: str, request: Request, db: Session = Depends(get_db)) -> Any:
|
||||
adapter = BillingRuleUpdateAdapter(rule_id=rule_id)
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
|
||||
@router.get("/collectors")
|
||||
async def list_dimension_collectors(
|
||||
request: Request,
|
||||
api_format: str | None = Query(None),
|
||||
task_type: str | None = Query(None),
|
||||
dimension_name: str | None = Query(None),
|
||||
is_enabled: bool | None = Query(None),
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(50, ge=1, le=200),
|
||||
db: Session = Depends(get_db),
|
||||
) -> Any:
|
||||
adapter = DimensionCollectorListAdapter(
|
||||
api_format=api_format,
|
||||
task_type=task_type,
|
||||
dimension_name=dimension_name,
|
||||
is_enabled=is_enabled,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
)
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
|
||||
@router.get("/collectors/{collector_id}")
|
||||
async def get_dimension_collector(
|
||||
collector_id: str, request: Request, db: Session = Depends(get_db)
|
||||
) -> Any:
|
||||
adapter = DimensionCollectorDetailAdapter(collector_id=collector_id)
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
|
||||
@router.post("/collectors")
|
||||
async def create_dimension_collector(request: Request, db: Session = Depends(get_db)) -> Any:
|
||||
adapter = DimensionCollectorCreateAdapter()
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
|
||||
@router.put("/collectors/{collector_id}")
|
||||
async def update_dimension_collector(
|
||||
collector_id: str, request: Request, db: Session = Depends(get_db)
|
||||
) -> Any:
|
||||
adapter = DimensionCollectorUpdateAdapter(collector_id=collector_id)
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Adapters
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class BillingRuleListAdapter(AdminApiAdapter):
|
||||
page: int
|
||||
page_size: int
|
||||
task_type: str | None = None
|
||||
is_enabled: bool | None = None
|
||||
|
||||
async def handle(self, context: ApiRequestContext) -> dict[str, Any]:
|
||||
q = context.db.query(BillingRule)
|
||||
if self.task_type:
|
||||
q = q.filter(BillingRule.task_type == self.task_type.lower())
|
||||
if self.is_enabled is not None:
|
||||
q = q.filter(BillingRule.is_enabled == self.is_enabled)
|
||||
|
||||
total = int(q.with_entities(func.count(BillingRule.id)).scalar() or 0)
|
||||
items = (
|
||||
q.order_by(BillingRule.updated_at.desc())
|
||||
.offset((self.page - 1) * self.page_size)
|
||||
.limit(self.page_size)
|
||||
.all()
|
||||
)
|
||||
return {
|
||||
"items": [BillingRuleResponse.from_orm_obj(r).model_dump() for r in items],
|
||||
"total": total,
|
||||
"page": self.page,
|
||||
"page_size": self.page_size,
|
||||
"pages": (total + self.page_size - 1) // self.page_size,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class BillingRuleDetailAdapter(AdminApiAdapter):
|
||||
rule_id: str
|
||||
|
||||
async def handle(self, context: ApiRequestContext) -> dict[str, Any]:
|
||||
rule = context.db.query(BillingRule).filter(BillingRule.id == self.rule_id).first()
|
||||
if not rule:
|
||||
raise NotFoundException("Billing rule not found")
|
||||
return BillingRuleResponse.from_orm_obj(rule).model_dump()
|
||||
|
||||
|
||||
class BillingRuleCreateAdapter(AdminApiAdapter):
|
||||
async def handle(self, context: ApiRequestContext) -> dict[str, Any]:
|
||||
payload = context.ensure_json_body()
|
||||
try:
|
||||
req = BillingRuleUpsertRequest.model_validate(payload)
|
||||
except Exception as exc:
|
||||
raise InvalidRequestException(f"Invalid request body: {exc}")
|
||||
|
||||
_validate_billing_rule_request(req)
|
||||
|
||||
rule = BillingRule(
|
||||
name=req.name,
|
||||
task_type=req.task_type,
|
||||
global_model_id=req.global_model_id,
|
||||
model_id=req.model_id,
|
||||
expression=req.expression,
|
||||
variables=req.variables,
|
||||
dimension_mappings=req.dimension_mappings,
|
||||
is_enabled=req.is_enabled,
|
||||
)
|
||||
context.db.add(rule)
|
||||
try:
|
||||
context.db.commit()
|
||||
except IntegrityError as exc:
|
||||
context.db.rollback()
|
||||
raise InvalidRequestException(f"Integrity error: {exc}")
|
||||
|
||||
context.db.refresh(rule)
|
||||
return BillingRuleResponse.from_orm_obj(rule).model_dump()
|
||||
|
||||
|
||||
@dataclass
|
||||
class BillingRuleUpdateAdapter(AdminApiAdapter):
|
||||
rule_id: str
|
||||
|
||||
async def handle(self, context: ApiRequestContext) -> dict[str, Any]:
|
||||
rule = context.db.query(BillingRule).filter(BillingRule.id == self.rule_id).first()
|
||||
if not rule:
|
||||
raise NotFoundException("Billing rule not found")
|
||||
|
||||
payload = context.ensure_json_body()
|
||||
try:
|
||||
req = BillingRuleUpsertRequest.model_validate(payload)
|
||||
except Exception as exc:
|
||||
raise InvalidRequestException(f"Invalid request body: {exc}")
|
||||
|
||||
_validate_billing_rule_request(req)
|
||||
|
||||
rule.name = req.name
|
||||
rule.task_type = req.task_type
|
||||
rule.global_model_id = req.global_model_id
|
||||
rule.model_id = req.model_id
|
||||
rule.expression = req.expression
|
||||
rule.variables = req.variables
|
||||
rule.dimension_mappings = req.dimension_mappings
|
||||
rule.is_enabled = req.is_enabled
|
||||
|
||||
try:
|
||||
context.db.commit()
|
||||
except IntegrityError as exc:
|
||||
context.db.rollback()
|
||||
raise InvalidRequestException(f"Integrity error: {exc}")
|
||||
|
||||
context.db.refresh(rule)
|
||||
return BillingRuleResponse.from_orm_obj(rule).model_dump()
|
||||
|
||||
|
||||
@dataclass
|
||||
class DimensionCollectorListAdapter(AdminApiAdapter):
|
||||
page: int
|
||||
page_size: int
|
||||
api_format: str | None = None
|
||||
task_type: str | None = None
|
||||
dimension_name: str | None = None
|
||||
is_enabled: bool | None = None
|
||||
|
||||
async def handle(self, context: ApiRequestContext) -> dict[str, Any]:
|
||||
q = context.db.query(DimensionCollector)
|
||||
if self.api_format:
|
||||
q = q.filter(DimensionCollector.api_format == self.api_format.upper())
|
||||
if self.task_type:
|
||||
q = q.filter(DimensionCollector.task_type == self.task_type.lower())
|
||||
if self.dimension_name:
|
||||
q = q.filter(DimensionCollector.dimension_name == self.dimension_name)
|
||||
if self.is_enabled is not None:
|
||||
q = q.filter(DimensionCollector.is_enabled == self.is_enabled)
|
||||
|
||||
total = int(q.with_entities(func.count(DimensionCollector.id)).scalar() or 0)
|
||||
items = (
|
||||
q.order_by(DimensionCollector.updated_at.desc())
|
||||
.offset((self.page - 1) * self.page_size)
|
||||
.limit(self.page_size)
|
||||
.all()
|
||||
)
|
||||
return {
|
||||
"items": [DimensionCollectorResponse.from_orm_obj(c).model_dump() for c in items],
|
||||
"total": total,
|
||||
"page": self.page,
|
||||
"page_size": self.page_size,
|
||||
"pages": (total + self.page_size - 1) // self.page_size,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class DimensionCollectorDetailAdapter(AdminApiAdapter):
|
||||
collector_id: str
|
||||
|
||||
async def handle(self, context: ApiRequestContext) -> dict[str, Any]:
|
||||
c = (
|
||||
context.db.query(DimensionCollector)
|
||||
.filter(DimensionCollector.id == self.collector_id)
|
||||
.first()
|
||||
)
|
||||
if not c:
|
||||
raise NotFoundException("Dimension collector not found")
|
||||
return DimensionCollectorResponse.from_orm_obj(c).model_dump()
|
||||
|
||||
|
||||
class DimensionCollectorCreateAdapter(AdminApiAdapter):
|
||||
async def handle(self, context: ApiRequestContext) -> dict[str, Any]:
|
||||
payload = context.ensure_json_body()
|
||||
try:
|
||||
req = DimensionCollectorUpsertRequest.model_validate(payload)
|
||||
except Exception as exc:
|
||||
raise InvalidRequestException(f"Invalid request body: {exc}")
|
||||
|
||||
_validate_dimension_collector_request(context.db, req, existing_id=None)
|
||||
|
||||
c = DimensionCollector(
|
||||
api_format=req.api_format.upper(),
|
||||
task_type=req.task_type.lower(),
|
||||
dimension_name=req.dimension_name,
|
||||
source_type=req.source_type,
|
||||
source_path=req.source_path,
|
||||
value_type=req.value_type,
|
||||
transform_expression=req.transform_expression,
|
||||
default_value=req.default_value,
|
||||
priority=req.priority,
|
||||
is_enabled=req.is_enabled,
|
||||
)
|
||||
context.db.add(c)
|
||||
try:
|
||||
context.db.commit()
|
||||
except IntegrityError as exc:
|
||||
context.db.rollback()
|
||||
raise InvalidRequestException(f"Integrity error: {exc}")
|
||||
|
||||
context.db.refresh(c)
|
||||
return DimensionCollectorResponse.from_orm_obj(c).model_dump()
|
||||
|
||||
|
||||
@dataclass
|
||||
class DimensionCollectorUpdateAdapter(AdminApiAdapter):
|
||||
collector_id: str
|
||||
|
||||
async def handle(self, context: ApiRequestContext) -> dict[str, Any]:
|
||||
c = (
|
||||
context.db.query(DimensionCollector)
|
||||
.filter(DimensionCollector.id == self.collector_id)
|
||||
.first()
|
||||
)
|
||||
if not c:
|
||||
raise NotFoundException("Dimension collector not found")
|
||||
|
||||
payload = context.ensure_json_body()
|
||||
try:
|
||||
req = DimensionCollectorUpsertRequest.model_validate(payload)
|
||||
except Exception as exc:
|
||||
raise InvalidRequestException(f"Invalid request body: {exc}")
|
||||
|
||||
_validate_dimension_collector_request(context.db, req, existing_id=self.collector_id)
|
||||
|
||||
c.api_format = req.api_format.upper()
|
||||
c.task_type = req.task_type.lower()
|
||||
c.dimension_name = req.dimension_name
|
||||
c.source_type = req.source_type
|
||||
c.source_path = req.source_path
|
||||
c.value_type = req.value_type
|
||||
c.transform_expression = req.transform_expression
|
||||
c.default_value = req.default_value
|
||||
c.priority = req.priority
|
||||
c.is_enabled = req.is_enabled
|
||||
|
||||
try:
|
||||
context.db.commit()
|
||||
except IntegrityError as exc:
|
||||
context.db.rollback()
|
||||
raise InvalidRequestException(f"Integrity error: {exc}")
|
||||
|
||||
context.db.refresh(c)
|
||||
return DimensionCollectorResponse.from_orm_obj(c).model_dump()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Validation helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _validate_billing_rule_request(req: BillingRuleUpsertRequest) -> None:
|
||||
# model/global_model 二选一
|
||||
if bool(req.global_model_id) == bool(req.model_id):
|
||||
raise InvalidRequestException("Exactly one of global_model_id or model_id must be provided")
|
||||
|
||||
# task_type 校验:Pydantic Literal 已限制为 "chat", "video", "image", "audio"
|
||||
# 注:CLI 在计费域等同于 chat,billing_rules 不存储 "cli"
|
||||
|
||||
# expression 安全校验
|
||||
try:
|
||||
_expr_validator.validate(req.expression)
|
||||
except UnsafeExpressionError as exc:
|
||||
raise InvalidRequestException(f"Invalid expression: {exc}")
|
||||
|
||||
# variables 必须为数值(JSON 可包含 int/float)
|
||||
if not isinstance(req.variables, dict):
|
||||
raise InvalidRequestException("variables must be a JSON object")
|
||||
for k, v in req.variables.items():
|
||||
if not isinstance(k, str) or not k:
|
||||
raise InvalidRequestException("variables keys must be non-empty strings")
|
||||
if isinstance(v, bool) or not isinstance(v, (int, float)):
|
||||
raise InvalidRequestException(f"variables['{k}'] must be a number")
|
||||
|
||||
# dimension_mappings 结构做轻量校验(详细 schema 由业务侧保障)
|
||||
if not isinstance(req.dimension_mappings, dict):
|
||||
raise InvalidRequestException("dimension_mappings must be a JSON object")
|
||||
for var_name, mapping in req.dimension_mappings.items():
|
||||
if not isinstance(var_name, str) or not var_name:
|
||||
raise InvalidRequestException("dimension_mappings keys must be non-empty strings")
|
||||
if not isinstance(mapping, dict):
|
||||
raise InvalidRequestException(f"dimension_mappings['{var_name}'] must be an object")
|
||||
if "source" not in mapping:
|
||||
raise InvalidRequestException(f"dimension_mappings['{var_name}'].source is required")
|
||||
|
||||
|
||||
def _validate_dimension_collector_request(
|
||||
db: Session,
|
||||
req: DimensionCollectorUpsertRequest,
|
||||
*,
|
||||
existing_id: str | None,
|
||||
) -> None:
|
||||
src = req.source_type
|
||||
if src == "computed":
|
||||
if req.source_path is not None:
|
||||
raise InvalidRequestException("computed collector must have source_path=null")
|
||||
if not req.transform_expression:
|
||||
raise InvalidRequestException("computed collector must have transform_expression")
|
||||
else:
|
||||
if not req.source_path:
|
||||
raise InvalidRequestException("non-computed collector must have source_path")
|
||||
|
||||
# transform_expression 安全校验(如配置)
|
||||
if req.transform_expression:
|
||||
try:
|
||||
_expr_validator.validate(req.transform_expression)
|
||||
except UnsafeExpressionError as exc:
|
||||
raise InvalidRequestException(f"Invalid transform_expression: {exc}")
|
||||
|
||||
# default_value 仅允许同一维度一条(enabled=true)
|
||||
if req.default_value is not None and req.is_enabled:
|
||||
q = db.query(DimensionCollector).filter(
|
||||
DimensionCollector.api_format == req.api_format.upper(),
|
||||
DimensionCollector.task_type == req.task_type.lower(),
|
||||
DimensionCollector.dimension_name == req.dimension_name,
|
||||
DimensionCollector.is_enabled.is_(True),
|
||||
DimensionCollector.default_value.isnot(None),
|
||||
)
|
||||
if existing_id:
|
||||
q = q.filter(DimensionCollector.id != existing_id)
|
||||
exists = db.query(q.exists()).scalar()
|
||||
if exists:
|
||||
raise InvalidRequestException(
|
||||
"default_value already exists for this (api_format, task_type, dimension_name)"
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class BillingPresetListAdapter(AdminApiAdapter):
|
||||
async def handle(self, context: ApiRequestContext) -> dict[str, Any]:
|
||||
items = []
|
||||
for p in list_preset_packs():
|
||||
items.append(
|
||||
BillingPresetInfoResponse(
|
||||
name=p.name,
|
||||
version=p.version,
|
||||
description=p.description,
|
||||
collector_count=len(p.collectors or []),
|
||||
).model_dump()
|
||||
)
|
||||
return {"items": items}
|
||||
|
||||
|
||||
class BillingPresetApplyAdapter(AdminApiAdapter):
|
||||
async def handle(self, context: ApiRequestContext) -> dict[str, Any]:
|
||||
payload = context.ensure_json_body()
|
||||
try:
|
||||
req = ApplyBillingPresetRequest.model_validate(payload)
|
||||
except Exception as exc:
|
||||
raise InvalidRequestException(f"Invalid request body: {exc}")
|
||||
|
||||
result = BillingPresetService.apply_preset(
|
||||
context.db,
|
||||
preset_name=req.preset,
|
||||
mode=req.mode,
|
||||
)
|
||||
if result.errors:
|
||||
# still return counts; caller can display partial results
|
||||
return {"ok": False, **result.to_dict()}
|
||||
return {"ok": True, **result.to_dict()}
|
||||
24
_deprecated_py_src/api/admin/endpoints/__init__.py
Normal file
24
_deprecated_py_src/api/admin/endpoints/__init__.py
Normal file
@@ -0,0 +1,24 @@
|
||||
"""Endpoint management API routers."""
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from .concurrency import router as concurrency_router
|
||||
from .health import router as health_router
|
||||
from .keys import router as keys_router
|
||||
from .routes import router as routes_router
|
||||
|
||||
router = APIRouter(prefix="/api/admin/endpoints", tags=["Admin - Endpoints"])
|
||||
|
||||
# Endpoint CRUD
|
||||
router.include_router(routes_router)
|
||||
|
||||
# Endpoint Keys management
|
||||
router.include_router(keys_router)
|
||||
|
||||
# Health monitoring
|
||||
router.include_router(health_router)
|
||||
|
||||
# Concurrency control
|
||||
router.include_router(concurrency_router)
|
||||
|
||||
__all__ = ["router"]
|
||||
99
_deprecated_py_src/api/admin/endpoints/concurrency.py
Normal file
99
_deprecated_py_src/api/admin/endpoints/concurrency.py
Normal file
@@ -0,0 +1,99 @@
|
||||
"""
|
||||
Key RPM 限制管理 API
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from src.api.base.admin_adapter import AdminApiAdapter
|
||||
from src.api.base.context import ApiRequestContext
|
||||
from src.api.base.pipeline import get_pipeline
|
||||
from src.core.exceptions import NotFoundException
|
||||
from src.database import get_db
|
||||
from src.models.database import ProviderAPIKey
|
||||
from src.models.endpoint_models import KeyRpmStatusResponse
|
||||
from src.services.rate_limit.concurrency_manager import get_concurrency_manager
|
||||
|
||||
router = APIRouter(tags=["RPM Control"])
|
||||
pipeline = get_pipeline()
|
||||
|
||||
|
||||
@router.get("/rpm/key/{key_id}", response_model=KeyRpmStatusResponse)
|
||||
async def get_key_rpm(
|
||||
key_id: str,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
) -> KeyRpmStatusResponse:
|
||||
"""
|
||||
获取 Key 当前 RPM 状态
|
||||
|
||||
查询指定 API Key 的实时 RPM 使用情况,包括当前 RPM 计数和最大 RPM 限制。
|
||||
|
||||
**路径参数**:
|
||||
- `key_id`: API Key ID
|
||||
|
||||
**返回字段**:
|
||||
- `key_id`: API Key ID
|
||||
- `current_rpm`: 当前 RPM 计数
|
||||
- `rpm_limit`: RPM 限制
|
||||
"""
|
||||
adapter = AdminKeyRpmAdapter(key_id=key_id)
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
|
||||
@router.delete("/rpm/key/{key_id}")
|
||||
async def reset_key_rpm(
|
||||
key_id: str,
|
||||
http_request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
"""
|
||||
重置 Key RPM 计数器
|
||||
|
||||
重置指定 API Key 的 RPM 计数器,用于解决计数不准确的问题。
|
||||
管理员功能,请谨慎使用。
|
||||
|
||||
**路径参数**:
|
||||
- `key_id`: API Key ID
|
||||
|
||||
**返回字段**:
|
||||
- `message`: 操作结果消息
|
||||
"""
|
||||
adapter = AdminResetKeyRpmAdapter(key_id=key_id)
|
||||
return await pipeline.run(adapter=adapter, http_request=http_request, db=db, mode=adapter.mode)
|
||||
|
||||
|
||||
# -------- Adapters --------
|
||||
|
||||
|
||||
@dataclass
|
||||
class AdminKeyRpmAdapter(AdminApiAdapter):
|
||||
key_id: str
|
||||
|
||||
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
||||
db = context.db
|
||||
key = db.query(ProviderAPIKey).filter(ProviderAPIKey.id == self.key_id).first()
|
||||
if not key:
|
||||
raise NotFoundException(f"Key {self.key_id} 不存在")
|
||||
|
||||
concurrency_manager = await get_concurrency_manager()
|
||||
key_count = await concurrency_manager.get_key_rpm_count(key_id=self.key_id)
|
||||
|
||||
return KeyRpmStatusResponse(
|
||||
key_id=self.key_id,
|
||||
current_rpm=key_count,
|
||||
rpm_limit=key.rpm_limit,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class AdminResetKeyRpmAdapter(AdminApiAdapter):
|
||||
key_id: str
|
||||
|
||||
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
||||
concurrency_manager = await get_concurrency_manager()
|
||||
await concurrency_manager.reset_key_rpm(key_id=self.key_id)
|
||||
return {"message": "RPM 计数已重置"}
|
||||
601
_deprecated_py_src/api/admin/endpoints/health.py
Normal file
601
_deprecated_py_src/api/admin/endpoints/health.py
Normal file
@@ -0,0 +1,601 @@
|
||||
"""
|
||||
Endpoint 健康监控 API
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections import defaultdict
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, Query, Request
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from src.api.base.admin_adapter import AdminApiAdapter
|
||||
from src.api.base.context import ApiRequestContext
|
||||
from src.api.base.pipeline import get_pipeline
|
||||
from src.core.exceptions import NotFoundException
|
||||
from src.core.logger import logger
|
||||
from src.database import get_db
|
||||
from src.models.database import Provider, ProviderAPIKey, ProviderEndpoint, RequestCandidate
|
||||
from src.models.endpoint_models import (
|
||||
ApiFormatHealthMonitor,
|
||||
ApiFormatHealthMonitorResponse,
|
||||
EndpointHealthEvent,
|
||||
HealthStatusResponse,
|
||||
HealthSummaryResponse,
|
||||
)
|
||||
from src.services.health.endpoint import EndpointHealthService
|
||||
from src.services.health.monitor import HealthMonitor, get_health_monitor
|
||||
|
||||
router = APIRouter(tags=["Endpoint Health"])
|
||||
|
||||
|
||||
def _recover_key_health_sync(db: Session, key_id: str, api_format: str | None) -> dict[str, Any]:
|
||||
key = db.query(ProviderAPIKey).filter(ProviderAPIKey.id == key_id).first()
|
||||
if not key:
|
||||
raise NotFoundException(f"Key {key_id} 不存在")
|
||||
|
||||
success = get_health_monitor().reset_health(db, key_id=key_id, api_format=api_format)
|
||||
if not success:
|
||||
raise Exception("重置健康度失败")
|
||||
|
||||
if not key.is_active:
|
||||
key.is_active = True # type: ignore[assignment]
|
||||
|
||||
db.commit()
|
||||
return {
|
||||
"is_active": bool(key.is_active),
|
||||
"api_format": api_format,
|
||||
}
|
||||
|
||||
|
||||
def _recover_all_keys_health_sync(db: Session) -> list[dict[str, Any]]:
|
||||
candidates = (
|
||||
db.query(ProviderAPIKey)
|
||||
.filter(
|
||||
ProviderAPIKey.circuit_breaker_by_format.isnot(None),
|
||||
ProviderAPIKey.circuit_breaker_by_format != "{}",
|
||||
)
|
||||
.all()
|
||||
)
|
||||
|
||||
circuit_open_keys = [
|
||||
key
|
||||
for key in candidates
|
||||
if any(cb.get("open") for cb in (key.circuit_breaker_by_format or {}).values())
|
||||
]
|
||||
|
||||
recovered_keys: list[dict[str, Any]] = []
|
||||
for key in circuit_open_keys:
|
||||
key.health_by_format = {} # type: ignore[assignment]
|
||||
key.circuit_breaker_by_format = {} # type: ignore[assignment]
|
||||
recovered_keys.append(
|
||||
{
|
||||
"key_id": key.id,
|
||||
"key_name": key.name,
|
||||
"provider_id": key.provider_id,
|
||||
"api_formats": key.api_formats,
|
||||
}
|
||||
)
|
||||
|
||||
if recovered_keys:
|
||||
db.commit()
|
||||
|
||||
return recovered_keys
|
||||
|
||||
|
||||
def _format_str(api_format_enum: Any) -> str:
|
||||
"""将 DB 查询返回的 api_format(可能是 enum 或 str)统一转为 str。"""
|
||||
return api_format_enum.value if hasattr(api_format_enum, "value") else str(api_format_enum)
|
||||
|
||||
|
||||
def _fetch_recent_attempts_for_api_format(
|
||||
db: Session,
|
||||
*,
|
||||
api_format: str,
|
||||
since: datetime,
|
||||
per_format_limit: int,
|
||||
) -> list[RequestCandidate]:
|
||||
"""获取单个 API 格式最近的最终态请求,用于事件展示。"""
|
||||
final_statuses = ["success", "failed", "skipped"]
|
||||
return (
|
||||
db.query(RequestCandidate)
|
||||
.join(ProviderEndpoint, RequestCandidate.endpoint_id == ProviderEndpoint.id)
|
||||
.join(Provider, ProviderEndpoint.provider_id == Provider.id)
|
||||
.filter(
|
||||
ProviderEndpoint.is_active.is_(True),
|
||||
Provider.is_active.is_(True),
|
||||
ProviderEndpoint.api_format == api_format,
|
||||
RequestCandidate.created_at >= since,
|
||||
RequestCandidate.status.in_(final_statuses),
|
||||
)
|
||||
.order_by(RequestCandidate.created_at.desc())
|
||||
.limit(per_format_limit)
|
||||
.all()
|
||||
)
|
||||
|
||||
|
||||
pipeline = get_pipeline()
|
||||
|
||||
|
||||
@router.get("/health/summary", response_model=HealthSummaryResponse)
|
||||
async def get_health_summary(
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
) -> HealthSummaryResponse:
|
||||
"""
|
||||
获取健康状态摘要
|
||||
|
||||
获取系统整体健康状态摘要,包括所有 Provider、Endpoint 和 Key 的健康状态统计。
|
||||
|
||||
**返回字段**:
|
||||
- `total_providers`: Provider 总数
|
||||
- `active_providers`: 活跃 Provider 数量
|
||||
- `total_endpoints`: Endpoint 总数
|
||||
- `active_endpoints`: 活跃 Endpoint 数量
|
||||
- `total_keys`: Key 总数
|
||||
- `active_keys`: 活跃 Key 数量
|
||||
- `circuit_breaker_open_keys`: 熔断的 Key 数量
|
||||
"""
|
||||
adapter = AdminHealthSummaryAdapter()
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
|
||||
@router.get("/health/status")
|
||||
async def get_endpoint_health_status(
|
||||
request: Request,
|
||||
lookback_hours: int = Query(6, ge=1, le=72, description="回溯的小时数"),
|
||||
db: Session = Depends(get_db),
|
||||
) -> Any:
|
||||
"""
|
||||
获取端点健康状态(简化视图,与用户端点统一)
|
||||
|
||||
获取按 API 格式聚合的端点健康状态时间线,基于 Usage 表统计,
|
||||
返回 50 个时间段的聚合状态,适用于快速查看整体健康趋势。
|
||||
|
||||
与 /health/api-formats 的区别:
|
||||
- /health/status: 返回聚合的时间线状态(50个时间段),基于 Usage 表
|
||||
- /health/api-formats: 返回详细的事件列表,基于 RequestCandidate 表
|
||||
|
||||
**查询参数**:
|
||||
- `lookback_hours`: 回溯的小时数(1-72),默认 6
|
||||
|
||||
**返回字段**:
|
||||
- `api_format`: API 格式名称
|
||||
- `timeline`: 时间线数据(50个时间段)
|
||||
- `time_range_start`: 时间范围起始
|
||||
- `time_range_end`: 时间范围结束
|
||||
"""
|
||||
adapter = AdminEndpointHealthStatusAdapter(lookback_hours=lookback_hours)
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
|
||||
@router.get("/health/api-formats", response_model=ApiFormatHealthMonitorResponse)
|
||||
async def get_api_format_health_monitor(
|
||||
request: Request,
|
||||
lookback_hours: int = Query(6, ge=1, le=72, description="回溯的小时数"),
|
||||
per_format_limit: int = Query(60, ge=10, le=200, description="每个 API 格式的事件数量"),
|
||||
db: Session = Depends(get_db),
|
||||
) -> ApiFormatHealthMonitorResponse:
|
||||
"""
|
||||
获取按 API 格式聚合的健康监控时间线(详细事件列表)
|
||||
|
||||
获取每个 API 格式的详细健康监控数据,包括请求事件列表、成功率统计、
|
||||
时间线数据等,基于 RequestCandidate 表查询,适用于详细分析。
|
||||
|
||||
**查询参数**:
|
||||
- `lookback_hours`: 回溯的小时数(1-72),默认 6
|
||||
- `per_format_limit`: 每个 API 格式返回的事件数量(10-200),默认 60
|
||||
|
||||
**返回字段**:
|
||||
- `generated_at`: 数据生成时间
|
||||
- `formats`: API 格式健康监控数据列表
|
||||
- `api_format`: API 格式名称
|
||||
- `total_attempts`: 总请求数
|
||||
- `success_count`: 成功请求数
|
||||
- `failed_count`: 失败请求数
|
||||
- `skipped_count`: 跳过请求数
|
||||
- `success_rate`: 成功率
|
||||
- `provider_count`: Provider 数量
|
||||
- `key_count`: Key 数量
|
||||
- `last_event_at`: 最后事件时间
|
||||
- `events`: 事件列表
|
||||
- `timeline`: 时间线数据
|
||||
- `time_range_start`: 时间范围起始
|
||||
- `time_range_end`: 时间范围结束
|
||||
"""
|
||||
adapter = AdminApiFormatHealthMonitorAdapter(
|
||||
lookback_hours=lookback_hours,
|
||||
per_format_limit=per_format_limit,
|
||||
)
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
|
||||
@router.get("/health/key/{key_id}", response_model=HealthStatusResponse)
|
||||
async def get_key_health(
|
||||
key_id: str,
|
||||
request: Request,
|
||||
api_format: str | None = Query(None, description="API 格式(可选,如 CLAUDE、OPENAI)"),
|
||||
db: Session = Depends(get_db),
|
||||
) -> HealthStatusResponse:
|
||||
"""
|
||||
获取 Key 健康状态
|
||||
|
||||
获取指定 API Key 的健康状态详情,包括健康分数、连续失败次数、
|
||||
熔断器状态等信息。支持按 API 格式查询。
|
||||
|
||||
**路径参数**:
|
||||
- `key_id`: API Key ID
|
||||
|
||||
**查询参数**:
|
||||
- `api_format`: 可选,指定 API 格式(如 CLAUDE、OPENAI)。
|
||||
- 指定时返回该格式的健康度详情
|
||||
- 不指定时返回所有格式的健康度摘要
|
||||
|
||||
**返回字段**:
|
||||
- `key_id`: API Key ID
|
||||
- `key_health_score`: 健康分数(0.0-1.0)
|
||||
- `key_is_active`: 是否活跃
|
||||
- `key_statistics`: 统计信息
|
||||
- `health_by_format`: 按格式的健康度数据(无 api_format 参数时)
|
||||
- `circuit_breaker_open`: 熔断器是否打开(有 api_format 参数时)
|
||||
"""
|
||||
adapter = AdminKeyHealthAdapter(key_id=key_id, api_format=api_format)
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
|
||||
@router.patch("/health/keys/{key_id}")
|
||||
async def recover_key_health(
|
||||
key_id: str,
|
||||
request: Request,
|
||||
api_format: str | None = Query(None, description="API 格式(可选,不指定则恢复所有格式)"),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
"""
|
||||
恢复 Key 健康状态
|
||||
|
||||
手动恢复指定 Key 的健康状态,将健康分数重置为 1.0,关闭熔断器,
|
||||
取消自动禁用,并重置所有失败计数。支持按 API 格式恢复。
|
||||
|
||||
**路径参数**:
|
||||
- `key_id`: API Key ID
|
||||
|
||||
**查询参数**:
|
||||
- `api_format`: 可选,指定 API 格式(如 CLAUDE、OPENAI)
|
||||
- 指定时仅恢复该格式的健康度
|
||||
- 不指定时恢复所有格式
|
||||
|
||||
**返回字段**:
|
||||
- `message`: 操作结果消息
|
||||
- `details`: 详细信息
|
||||
- `health_score`: 健康分数
|
||||
- `circuit_breaker_open`: 熔断器状态
|
||||
- `is_active`: 是否活跃
|
||||
"""
|
||||
adapter = AdminRecoverKeyHealthAdapter(key_id=key_id, api_format=api_format)
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
|
||||
@router.patch("/health/keys")
|
||||
async def recover_all_keys_health(
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
"""
|
||||
批量恢复所有熔断 Key 的健康状态
|
||||
|
||||
查找所有处于熔断状态的 Key(circuit_breaker_open=True),
|
||||
并批量执行以下操作:
|
||||
1. 将健康分数重置为 1.0
|
||||
2. 关闭熔断器
|
||||
3. 重置失败计数
|
||||
|
||||
**返回字段**:
|
||||
- `message`: 操作结果消息
|
||||
- `recovered_count`: 恢复的 Key 数量
|
||||
- `recovered_keys`: 恢复的 Key 列表
|
||||
- `key_id`: Key ID
|
||||
- `key_name`: Key 名称
|
||||
- `endpoint_id`: Endpoint ID
|
||||
"""
|
||||
adapter = AdminRecoverAllKeysHealthAdapter()
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
|
||||
# -------- Adapters --------
|
||||
|
||||
|
||||
class AdminHealthSummaryAdapter(AdminApiAdapter):
|
||||
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
||||
summary = get_health_monitor().get_all_health_status(context.db)
|
||||
return HealthSummaryResponse(**summary)
|
||||
|
||||
|
||||
@dataclass
|
||||
class AdminEndpointHealthStatusAdapter(AdminApiAdapter):
|
||||
"""管理员端点健康状态适配器(与用户端点统一,但包含管理员字段)"""
|
||||
|
||||
lookback_hours: int
|
||||
|
||||
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
||||
db = context.db
|
||||
|
||||
# 使用共享服务获取健康状态(管理员视图)
|
||||
result = EndpointHealthService.get_endpoint_health_by_format(
|
||||
db=db,
|
||||
lookback_hours=self.lookback_hours,
|
||||
include_admin_fields=True, # 包含管理员字段
|
||||
use_cache=False, # 管理员不使用缓存,确保实时性
|
||||
)
|
||||
|
||||
context.add_audit_metadata(
|
||||
action="endpoint_health_status",
|
||||
format_count=len(result),
|
||||
lookback_hours=self.lookback_hours,
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@dataclass
|
||||
class AdminApiFormatHealthMonitorAdapter(AdminApiAdapter):
|
||||
lookback_hours: int
|
||||
per_format_limit: int
|
||||
|
||||
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
||||
db = context.db
|
||||
now = datetime.now(timezone.utc)
|
||||
since = now - timedelta(hours=self.lookback_hours)
|
||||
|
||||
# 1. 单次查询获取所有活跃 endpoint 行,在内存中聚合 provider_count / endpoint_map
|
||||
endpoint_rows = (
|
||||
db.query(ProviderEndpoint.api_format, ProviderEndpoint.id, ProviderEndpoint.provider_id)
|
||||
.join(Provider, ProviderEndpoint.provider_id == Provider.id)
|
||||
.filter(
|
||||
ProviderEndpoint.is_active.is_(True),
|
||||
Provider.is_active.is_(True),
|
||||
)
|
||||
.all()
|
||||
)
|
||||
|
||||
all_formats: dict[str, int] = {} # api_format -> distinct provider count
|
||||
endpoint_map: dict[str, list[str]] = defaultdict(list)
|
||||
active_provider_formats: set[tuple[str, str]] = set()
|
||||
_provider_sets: dict[str, set[str]] = defaultdict(set)
|
||||
|
||||
for api_format_enum, endpoint_id, provider_id in endpoint_rows:
|
||||
fmt = _format_str(api_format_enum)
|
||||
endpoint_map[fmt].append(endpoint_id)
|
||||
_provider_sets[fmt].add(str(provider_id))
|
||||
active_provider_formats.add((str(provider_id), fmt))
|
||||
|
||||
for fmt, pids in _provider_sets.items():
|
||||
all_formats[fmt] = len(pids)
|
||||
|
||||
# 1.2 统计每个 API 格式可用的活跃 Key 数量(Key 属于 Provider,通过 api_formats 关联格式)
|
||||
key_counts: dict[str, int] = {}
|
||||
if active_provider_formats:
|
||||
active_provider_keys = (
|
||||
db.query(ProviderAPIKey.provider_id, ProviderAPIKey.api_formats)
|
||||
.join(Provider, ProviderAPIKey.provider_id == Provider.id)
|
||||
.filter(
|
||||
Provider.is_active.is_(True),
|
||||
ProviderAPIKey.is_active.is_(True),
|
||||
)
|
||||
.all()
|
||||
)
|
||||
for provider_id, api_formats in active_provider_keys:
|
||||
pid = str(provider_id)
|
||||
for fmt in api_formats or []:
|
||||
if (pid, fmt) not in active_provider_formats:
|
||||
continue
|
||||
key_counts[fmt] = key_counts.get(fmt, 0) + 1
|
||||
|
||||
# 2. 统计窗口内每个 API 格式的请求状态分布(真实统计)
|
||||
# 只统计最终状态:success, failed, skipped
|
||||
final_statuses = ["success", "failed", "skipped"]
|
||||
status_counts_query = (
|
||||
db.query(
|
||||
ProviderEndpoint.api_format,
|
||||
RequestCandidate.status,
|
||||
func.count(RequestCandidate.id).label("count"),
|
||||
)
|
||||
.join(RequestCandidate, ProviderEndpoint.id == RequestCandidate.endpoint_id)
|
||||
.join(Provider, ProviderEndpoint.provider_id == Provider.id)
|
||||
.filter(
|
||||
ProviderEndpoint.is_active.is_(True),
|
||||
Provider.is_active.is_(True),
|
||||
RequestCandidate.created_at >= since,
|
||||
RequestCandidate.status.in_(final_statuses),
|
||||
)
|
||||
.group_by(ProviderEndpoint.api_format, RequestCandidate.status)
|
||||
.all()
|
||||
)
|
||||
|
||||
# 构建每个格式的状态统计
|
||||
status_counts: dict[str, dict[str, int]] = {}
|
||||
for api_format_enum, status, count in status_counts_query:
|
||||
fmt = _format_str(api_format_enum)
|
||||
if fmt not in status_counts:
|
||||
status_counts[fmt] = {"success": 0, "failed": 0, "skipped": 0}
|
||||
status_counts[fmt][status] = count
|
||||
|
||||
# 3. 为所有活跃格式生成监控数据(包括没有请求记录的)
|
||||
monitors: list[ApiFormatHealthMonitor] = []
|
||||
for api_format in all_formats:
|
||||
attempts = _fetch_recent_attempts_for_api_format(
|
||||
db=db,
|
||||
api_format=api_format,
|
||||
since=since,
|
||||
per_format_limit=self.per_format_limit,
|
||||
)
|
||||
# 获取窗口内的真实统计数据
|
||||
# 只统计最终状态:success, failed, skipped
|
||||
# 中间状态(available, pending, used, started)不计入统计
|
||||
format_stats = status_counts.get(api_format, {"success": 0, "failed": 0, "skipped": 0})
|
||||
real_success_count = format_stats.get("success", 0)
|
||||
real_failed_count = format_stats.get("failed", 0)
|
||||
real_skipped_count = format_stats.get("skipped", 0)
|
||||
# total_attempts 只包含最终状态的请求数
|
||||
total_attempts = real_success_count + real_failed_count + real_skipped_count
|
||||
|
||||
# 时间线按时间正序
|
||||
attempts_sorted = list(reversed(attempts))
|
||||
events: list[EndpointHealthEvent] = []
|
||||
for attempt in attempts_sorted:
|
||||
event_timestamp = attempt.finished_at or attempt.started_at or attempt.created_at
|
||||
events.append(
|
||||
EndpointHealthEvent(
|
||||
timestamp=event_timestamp,
|
||||
status=attempt.status,
|
||||
status_code=attempt.status_code,
|
||||
latency_ms=attempt.latency_ms,
|
||||
error_type=attempt.error_type,
|
||||
error_message=attempt.error_message,
|
||||
)
|
||||
)
|
||||
|
||||
# 成功率 = success / (success + failed)
|
||||
# skipped 不算失败,不计入成功率分母
|
||||
# 无实际完成请求时成功率为 1.0(灰色状态)
|
||||
actual_completed = real_success_count + real_failed_count
|
||||
success_rate = real_success_count / actual_completed if actual_completed > 0 else 1.0
|
||||
last_event_at = events[-1].timestamp if events else None
|
||||
|
||||
# 生成 Usage 基于时间窗口的健康时间线
|
||||
timeline_data = EndpointHealthService._generate_timeline_from_usage(
|
||||
db=db,
|
||||
endpoint_ids=endpoint_map.get(api_format, []),
|
||||
now=now,
|
||||
lookback_hours=self.lookback_hours,
|
||||
)
|
||||
|
||||
monitors.append(
|
||||
ApiFormatHealthMonitor(
|
||||
api_format=api_format,
|
||||
total_attempts=total_attempts, # 真实总请求数
|
||||
success_count=real_success_count, # 真实成功数
|
||||
failed_count=real_failed_count, # 真实失败数
|
||||
skipped_count=real_skipped_count, # 真实跳过数
|
||||
success_rate=success_rate, # 基于真实统计的成功率
|
||||
provider_count=all_formats[api_format],
|
||||
key_count=key_counts.get(api_format, 0),
|
||||
last_event_at=last_event_at,
|
||||
events=events, # 限制为 per_format_limit 条(用于时间线显示)
|
||||
timeline=timeline_data.get("timeline", []),
|
||||
time_range_start=timeline_data.get("time_range_start"),
|
||||
time_range_end=timeline_data.get("time_range_end"),
|
||||
)
|
||||
)
|
||||
|
||||
response = ApiFormatHealthMonitorResponse(
|
||||
generated_at=now,
|
||||
formats=monitors,
|
||||
)
|
||||
context.add_audit_metadata(
|
||||
action="api_format_health_monitor",
|
||||
format_count=len(monitors),
|
||||
lookback_hours=self.lookback_hours,
|
||||
per_format_limit=self.per_format_limit,
|
||||
)
|
||||
return response
|
||||
|
||||
|
||||
@dataclass
|
||||
class AdminKeyHealthAdapter(AdminApiAdapter):
|
||||
key_id: str
|
||||
api_format: str | None = None
|
||||
|
||||
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
||||
health_data = get_health_monitor().get_key_health(context.db, self.key_id, self.api_format)
|
||||
if not health_data:
|
||||
raise NotFoundException(f"Key {self.key_id} 不存在")
|
||||
|
||||
# 构建响应
|
||||
response_data = {
|
||||
"key_id": health_data["key_id"],
|
||||
"key_is_active": health_data["is_active"],
|
||||
"key_statistics": health_data.get("statistics"),
|
||||
"key_health_score": health_data.get("health_score", 1.0),
|
||||
}
|
||||
|
||||
if self.api_format:
|
||||
# 单格式查询
|
||||
response_data["api_format"] = self.api_format
|
||||
response_data["key_consecutive_failures"] = health_data.get("consecutive_failures")
|
||||
response_data["key_last_failure_at"] = health_data.get("last_failure_at")
|
||||
circuit = health_data.get("circuit_breaker", {})
|
||||
response_data["circuit_breaker_open"] = circuit.get("open", False)
|
||||
response_data["circuit_breaker_open_at"] = circuit.get("open_at")
|
||||
response_data["next_probe_at"] = circuit.get("next_probe_at")
|
||||
response_data["half_open_until"] = circuit.get("half_open_until")
|
||||
response_data["half_open_successes"] = circuit.get("half_open_successes", 0)
|
||||
response_data["half_open_failures"] = circuit.get("half_open_failures", 0)
|
||||
else:
|
||||
# 全格式查询
|
||||
response_data["any_circuit_open"] = health_data.get("any_circuit_open", False)
|
||||
response_data["health_by_format"] = health_data.get("health_by_format")
|
||||
|
||||
return HealthStatusResponse(**response_data)
|
||||
|
||||
|
||||
@dataclass
|
||||
class AdminRecoverKeyHealthAdapter(AdminApiAdapter):
|
||||
key_id: str
|
||||
api_format: str | None = None
|
||||
|
||||
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
||||
db = context.db
|
||||
await asyncio.to_thread(_recover_key_health_sync, db, self.key_id, self.api_format)
|
||||
|
||||
if self.api_format:
|
||||
logger.info(f"管理员恢复Key健康状态: {self.key_id}/{self.api_format}")
|
||||
return {
|
||||
"message": f"Key 的 {self.api_format} 格式已恢复",
|
||||
"details": {
|
||||
"api_format": self.api_format,
|
||||
"health_score": 1.0,
|
||||
"circuit_breaker_open": False,
|
||||
"is_active": True,
|
||||
},
|
||||
}
|
||||
else:
|
||||
logger.info(f"管理员恢复Key健康状态: {self.key_id} (所有格式)")
|
||||
return {
|
||||
"message": "Key 所有格式已恢复",
|
||||
"details": {
|
||||
"health_score": 1.0,
|
||||
"circuit_breaker_open": False,
|
||||
"is_active": True,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class AdminRecoverAllKeysHealthAdapter(AdminApiAdapter):
|
||||
"""批量恢复所有熔断 Key 的健康状态"""
|
||||
|
||||
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
||||
db = context.db
|
||||
recovered_keys = await asyncio.to_thread(_recover_all_keys_health_sync, db)
|
||||
|
||||
if not recovered_keys:
|
||||
return {
|
||||
"message": "没有需要恢复的 Key",
|
||||
"recovered_count": 0,
|
||||
"recovered_keys": [],
|
||||
}
|
||||
|
||||
# 重置健康监控器的熔断计数
|
||||
HealthMonitor.reset_open_circuit_count()
|
||||
|
||||
logger.info(f"管理员批量恢复 {len(recovered_keys)} 个 Key 的健康状态")
|
||||
|
||||
return {
|
||||
"message": f"已恢复 {len(recovered_keys)} 个 Key",
|
||||
"recovered_count": len(recovered_keys),
|
||||
"recovered_keys": recovered_keys,
|
||||
}
|
||||
437
_deprecated_py_src/api/admin/endpoints/keys.py
Normal file
437
_deprecated_py_src/api/admin/endpoints/keys.py
Normal file
@@ -0,0 +1,437 @@
|
||||
"""
|
||||
Provider API Keys 管理
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, Query, Request
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from src.api.base.admin_adapter import AdminApiAdapter
|
||||
from src.api.base.context import ApiRequestContext
|
||||
from src.api.base.pipeline import get_pipeline
|
||||
from src.database import get_db
|
||||
from src.models.database import User
|
||||
from src.models.endpoint_models import (
|
||||
EndpointAPIKeyCreate,
|
||||
EndpointAPIKeyResponse,
|
||||
EndpointAPIKeyUpdate,
|
||||
)
|
||||
from src.services.provider_keys import (
|
||||
batch_delete_endpoint_keys_response,
|
||||
clear_oauth_invalid_response,
|
||||
create_provider_key_response,
|
||||
delete_endpoint_key_response,
|
||||
export_oauth_key_data,
|
||||
)
|
||||
from src.services.provider_keys import get_keys_grouped_by_format as query_keys_grouped_by_format
|
||||
from src.services.provider_keys import (
|
||||
list_provider_keys_responses,
|
||||
refresh_provider_quota_for_provider,
|
||||
reveal_endpoint_key_payload,
|
||||
update_endpoint_key_response,
|
||||
)
|
||||
from src.services.provider_keys.key_quota_service import (
|
||||
CODEX_WHAM_USAGE_URL as _CODEX_WHAM_USAGE_URL,
|
||||
)
|
||||
from src.utils.auth_utils import require_admin
|
||||
|
||||
router = APIRouter(tags=["Provider Keys"])
|
||||
pipeline = get_pipeline()
|
||||
|
||||
|
||||
@router.put("/keys/{key_id}", response_model=EndpointAPIKeyResponse)
|
||||
async def update_endpoint_key(
|
||||
key_id: str,
|
||||
key_data: EndpointAPIKeyUpdate,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
) -> EndpointAPIKeyResponse:
|
||||
"""
|
||||
更新 Provider Key
|
||||
|
||||
更新指定 Key 的配置,支持修改并发限制、速率倍数、优先级、
|
||||
配额限制、能力限制等。支持部分更新。
|
||||
|
||||
**路径参数**:
|
||||
- `key_id`: Key ID
|
||||
|
||||
**请求体字段**(均为可选):
|
||||
- `api_key`: 新的 API Key 原文
|
||||
- `name`: Key 名称
|
||||
- `note`: 备注
|
||||
- `rate_multipliers`: 按 API 格式的成本倍率
|
||||
- `internal_priority`: 内部优先级
|
||||
- `rpm_limit`: RPM 限制(设置为 null 可切换到自适应模式)
|
||||
- `allowed_models`: 允许的模型列表
|
||||
- `capabilities`: 能力配置
|
||||
- `is_active`: 是否活跃
|
||||
|
||||
**返回字段**:
|
||||
- 包含更新后的完整 Key 信息
|
||||
"""
|
||||
adapter = AdminUpdateEndpointKeyAdapter(key_id=key_id, key_data=key_data)
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
|
||||
@router.get("/keys/grouped-by-format")
|
||||
async def get_keys_grouped_by_format(
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
"""
|
||||
获取按 API 格式分组的所有 Keys
|
||||
|
||||
获取所有活跃的 Key,按 API 格式分组返回,用于全局优先级管理。
|
||||
每个 Key 包含基本信息、健康度指标、能力标签等。
|
||||
|
||||
**返回字段**:
|
||||
- 返回一个字典,键为 API 格式,值为该格式下的 Key 列表
|
||||
- 每个 Key 包含:
|
||||
- `id`: Key ID
|
||||
- `name`: Key 名称
|
||||
- `api_key_masked`: 脱敏后的 API Key
|
||||
- `internal_priority`: 内部优先级
|
||||
- `global_priority_by_format`: 按 API 格式的全局优先级
|
||||
- `format_priority`: 当前格式的优先级
|
||||
- `rate_multipliers`: 按 API 格式的成本倍率
|
||||
- `is_active`: 是否活跃
|
||||
- `circuit_breaker_open`: 熔断器状态
|
||||
- `provider_name`: Provider 名称
|
||||
- `endpoint_base_url`: Endpoint 基础 URL
|
||||
- `api_format`: API 格式
|
||||
- `capabilities`: 能力简称列表
|
||||
- `success_rate`: 成功率
|
||||
- `avg_response_time_ms`: 平均响应时间
|
||||
- `request_count`: 请求总数
|
||||
"""
|
||||
adapter = AdminGetKeysGroupedByFormatAdapter()
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
|
||||
@router.get("/keys/{key_id}/reveal")
|
||||
async def reveal_endpoint_key(
|
||||
key_id: str,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
"""
|
||||
获取完整的 API Key
|
||||
|
||||
解密并返回指定 Key 的完整原文,用于查看和复制。
|
||||
此操作会被记录到审计日志。
|
||||
|
||||
**路径参数**:
|
||||
- `key_id`: Key ID
|
||||
|
||||
**返回字段**:
|
||||
- `api_key`: 完整的 API Key 原文
|
||||
"""
|
||||
adapter = AdminRevealEndpointKeyAdapter(key_id=key_id)
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
|
||||
@router.get("/keys/{key_id}/export")
|
||||
async def export_key(
|
||||
key_id: str,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
_: User = Depends(require_admin),
|
||||
) -> dict:
|
||||
"""
|
||||
导出 OAuth Key 凭据(用于跨实例迁移)
|
||||
|
||||
解密 auth_config,返回精简的扁平 JSON,去掉 null 和临时字段。
|
||||
所有 OAuth Provider 格式统一。
|
||||
|
||||
**路径参数**:
|
||||
- `key_id`: Key ID
|
||||
"""
|
||||
adapter = AdminExportKeyAdapter(key_id=key_id)
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
|
||||
@router.delete("/keys/{key_id}")
|
||||
async def delete_endpoint_key(
|
||||
key_id: str,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
"""
|
||||
删除 Provider Key
|
||||
|
||||
删除指定的 API Key。此操作不可逆,请谨慎使用。
|
||||
|
||||
**路径参数**:
|
||||
- `key_id`: Key ID
|
||||
|
||||
**返回字段**:
|
||||
- `message`: 操作结果消息
|
||||
"""
|
||||
adapter = AdminDeleteEndpointKeyAdapter(key_id=key_id)
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
|
||||
@router.post("/keys/batch-delete")
|
||||
async def batch_delete_endpoint_keys(
|
||||
request: Request,
|
||||
ids: list[str] = Body(..., embed=True, max_length=100),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
"""
|
||||
批量删除 Provider Keys
|
||||
|
||||
一次性删除多个 Key,按 Provider 聚合执行副作用(缓存失效、模型关联检查),
|
||||
避免逐个删除导致的重复 Redis 操作和性能问题。
|
||||
|
||||
**请求体字段**:
|
||||
- `ids`: Key ID 列表(最多 100 个)
|
||||
|
||||
**返回字段**:
|
||||
- `success_count`: 成功删除的数量
|
||||
- `failed_count`: 失败的数量
|
||||
- `failed`: 失败的详情列表
|
||||
"""
|
||||
adapter = AdminBatchDeleteEndpointKeysAdapter(ids=ids)
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
|
||||
@router.post("/keys/{key_id}/clear-oauth-invalid")
|
||||
async def clear_oauth_invalid(
|
||||
key_id: str,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
_: User = Depends(require_admin),
|
||||
) -> dict:
|
||||
"""
|
||||
清除 Key 的 OAuth 失效标记
|
||||
|
||||
手动清除指定 Key 的 oauth_invalid_at / oauth_invalid_reason 状态,
|
||||
通常在管理员确认账号已完成验证后使用。
|
||||
|
||||
这是 admin/status 维修入口,不是 AI 运行时请求恢复路径。
|
||||
Rust 热路径迁移完成后,这里仍负责人工解除 OAuth invalid 标记。
|
||||
|
||||
**路径参数**:
|
||||
- `key_id`: Key ID
|
||||
|
||||
**返回字段**:
|
||||
- `message`: 操作结果消息
|
||||
"""
|
||||
adapter = AdminClearOAuthInvalidAdapter(key_id=key_id)
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
|
||||
# ========== Provider Keys API ==========
|
||||
|
||||
|
||||
@router.get("/providers/{provider_id}/keys", response_model=list[EndpointAPIKeyResponse])
|
||||
async def list_provider_keys(
|
||||
provider_id: str,
|
||||
request: Request,
|
||||
skip: int = Query(0, ge=0, description="跳过的记录数"),
|
||||
limit: int = Query(100, ge=1, le=1000, description="返回的最大记录数"),
|
||||
db: Session = Depends(get_db),
|
||||
) -> list[EndpointAPIKeyResponse]:
|
||||
"""
|
||||
获取 Provider 的所有 Keys
|
||||
|
||||
获取指定 Provider 下的所有 API Key 列表,支持多 API 格式。
|
||||
结果按优先级和创建时间排序。
|
||||
|
||||
**路径参数**:
|
||||
- `provider_id`: Provider ID
|
||||
|
||||
**查询参数**:
|
||||
- `skip`: 跳过的记录数,用于分页(默认 0)
|
||||
- `limit`: 返回的最大记录数(1-1000,默认 100)
|
||||
"""
|
||||
adapter = AdminListProviderKeysAdapter(
|
||||
provider_id=provider_id,
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
)
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
|
||||
@router.post("/providers/{provider_id}/keys", response_model=EndpointAPIKeyResponse)
|
||||
async def add_provider_key(
|
||||
provider_id: str,
|
||||
key_data: EndpointAPIKeyCreate,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
) -> EndpointAPIKeyResponse:
|
||||
"""
|
||||
为 Provider 添加 Key
|
||||
|
||||
为指定 Provider 添加新的 API Key,支持配置多个 API 格式。
|
||||
|
||||
**路径参数**:
|
||||
- `provider_id`: Provider ID
|
||||
|
||||
**请求体字段**:
|
||||
- `api_formats`: 支持的 API 格式列表(必填)
|
||||
- `api_key`: API Key 原文(将被加密存储)
|
||||
- `name`: Key 名称
|
||||
- 其他配置字段同 Key
|
||||
"""
|
||||
adapter = AdminCreateProviderKeyAdapter(provider_id=provider_id, key_data=key_data)
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
|
||||
# -------- Adapters --------
|
||||
|
||||
|
||||
@dataclass
|
||||
class AdminUpdateEndpointKeyAdapter(AdminApiAdapter):
|
||||
key_id: str
|
||||
key_data: EndpointAPIKeyUpdate
|
||||
|
||||
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
||||
return await update_endpoint_key_response(
|
||||
db=context.db,
|
||||
key_id=self.key_id,
|
||||
key_data=self.key_data,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class AdminRevealEndpointKeyAdapter(AdminApiAdapter):
|
||||
"""获取完整的 API Key 或 Auth Config(用于查看和复制)"""
|
||||
|
||||
key_id: str
|
||||
|
||||
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
||||
return reveal_endpoint_key_payload(context.db, self.key_id)
|
||||
|
||||
|
||||
@dataclass
|
||||
class AdminExportKeyAdapter(AdminApiAdapter):
|
||||
"""导出 OAuth Key 凭据:解密 auth_config,委托 provider-specific builder 构建导出数据。"""
|
||||
|
||||
key_id: str
|
||||
|
||||
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
||||
return export_oauth_key_data(context.db, self.key_id)
|
||||
|
||||
|
||||
@dataclass
|
||||
class AdminDeleteEndpointKeyAdapter(AdminApiAdapter):
|
||||
key_id: str
|
||||
|
||||
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
||||
return await delete_endpoint_key_response(db=context.db, key_id=self.key_id)
|
||||
|
||||
|
||||
@dataclass
|
||||
class AdminBatchDeleteEndpointKeysAdapter(AdminApiAdapter):
|
||||
"""批量删除多个 Provider Key"""
|
||||
|
||||
ids: list[str]
|
||||
|
||||
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
||||
return await batch_delete_endpoint_keys_response(db=context.db, key_ids=self.ids)
|
||||
|
||||
|
||||
@dataclass
|
||||
class AdminClearOAuthInvalidAdapter(AdminApiAdapter):
|
||||
"""清除 Key 的 OAuth 失效标记。"""
|
||||
|
||||
key_id: str
|
||||
|
||||
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
||||
return clear_oauth_invalid_response(context.db, self.key_id)
|
||||
|
||||
|
||||
class AdminGetKeysGroupedByFormatAdapter(AdminApiAdapter):
|
||||
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
||||
return query_keys_grouped_by_format(context.db)
|
||||
|
||||
|
||||
# ========== Adapters ==========
|
||||
|
||||
|
||||
@dataclass
|
||||
class AdminListProviderKeysAdapter(AdminApiAdapter):
|
||||
"""获取 Provider 的所有 Keys"""
|
||||
|
||||
provider_id: str
|
||||
skip: int
|
||||
limit: int
|
||||
|
||||
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
||||
return list_provider_keys_responses(context.db, self.provider_id, self.skip, self.limit)
|
||||
|
||||
|
||||
@dataclass
|
||||
class AdminCreateProviderKeyAdapter(AdminApiAdapter):
|
||||
"""为 Provider 添加 Key"""
|
||||
|
||||
provider_id: str
|
||||
key_data: EndpointAPIKeyCreate
|
||||
|
||||
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
||||
return await create_provider_key_response(
|
||||
db=context.db,
|
||||
provider_id=self.provider_id,
|
||||
key_data=self.key_data,
|
||||
)
|
||||
|
||||
|
||||
# ========== Quota Refresh API ==========
|
||||
|
||||
|
||||
class RefreshProviderQuotaRequest(BaseModel):
|
||||
key_ids: list[str] | None = Field(default=None, description="仅刷新指定 Key 列表(可选)")
|
||||
|
||||
|
||||
@router.post("/providers/{provider_id}/refresh-quota")
|
||||
async def refresh_provider_quota(
|
||||
provider_id: str,
|
||||
request: Request,
|
||||
payload: RefreshProviderQuotaRequest | None = None,
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
"""
|
||||
刷新 Provider 所有 Keys 的限额信息
|
||||
|
||||
支持的 Provider 类型:
|
||||
- Codex: 调用 wham/usage API 获取限额
|
||||
- Antigravity: 调用 fetchAvailableModels 获取配额
|
||||
- Kiro: 调用 getUsageLimits API 获取使用额度
|
||||
|
||||
**路径参数**:
|
||||
- `provider_id`: Provider ID
|
||||
**请求体**(可选):
|
||||
- `key_ids`: 仅刷新指定 Key 列表,不传时刷新所有活跃 Key
|
||||
|
||||
**返回字段**:
|
||||
- `success`: 成功刷新的 Key 数量
|
||||
- `failed`: 失败的 Key 数量
|
||||
- `results`: 每个 Key 的刷新结果
|
||||
"""
|
||||
adapter = AdminRefreshProviderQuotaAdapter(
|
||||
provider_id=provider_id,
|
||||
key_ids=payload.key_ids if payload else None,
|
||||
)
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
|
||||
@dataclass
|
||||
class AdminRefreshProviderQuotaAdapter(AdminApiAdapter):
|
||||
"""刷新 Provider 所有 Keys 的限额信息"""
|
||||
|
||||
provider_id: str
|
||||
key_ids: list[str] | None = None
|
||||
|
||||
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
||||
return await refresh_provider_quota_for_provider(
|
||||
db=context.db,
|
||||
provider_id=self.provider_id,
|
||||
codex_wham_usage_url=_CODEX_WHAM_USAGE_URL,
|
||||
key_ids=self.key_ids,
|
||||
)
|
||||
637
_deprecated_py_src/api/admin/endpoints/routes.py
Normal file
637
_deprecated_py_src/api/admin/endpoints/routes.py
Normal file
@@ -0,0 +1,637 @@
|
||||
"""
|
||||
ProviderEndpoint CRUD 管理 API
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, Query, Request
|
||||
from sqlalchemy import and_
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy.orm.attributes import flag_modified
|
||||
|
||||
from src.api.base.admin_adapter import AdminApiAdapter
|
||||
from src.api.base.context import ApiRequestContext
|
||||
from src.api.base.models_service import invalidate_models_list_cache
|
||||
from src.api.base.pipeline import get_pipeline
|
||||
from src.core.api_format.metadata import get_default_body_rules_for_endpoint
|
||||
from src.core.api_format.signature import parse_signature_key
|
||||
from src.core.exceptions import InvalidRequestException, NotFoundException
|
||||
from src.core.logger import logger
|
||||
from src.core.provider_templates.fixed_providers import FIXED_PROVIDERS
|
||||
from src.core.provider_types import ProviderType
|
||||
from src.database import get_db
|
||||
from src.models.database import Provider, ProviderAPIKey, ProviderEndpoint
|
||||
from src.models.endpoint_models import (
|
||||
ProviderEndpointCreate,
|
||||
ProviderEndpointResponse,
|
||||
ProviderEndpointUpdate,
|
||||
)
|
||||
from src.services.provider.stream_policy import UpstreamStreamPolicy, parse_upstream_stream_policy
|
||||
|
||||
router = APIRouter(tags=["Endpoint Management"])
|
||||
pipeline = get_pipeline()
|
||||
|
||||
|
||||
def mask_proxy_password(proxy_config: dict | None) -> dict | None:
|
||||
"""对代理配置中的密码进行脱敏处理"""
|
||||
if not proxy_config:
|
||||
return None
|
||||
masked = dict(proxy_config)
|
||||
if masked.get("password"):
|
||||
masked["password"] = "***"
|
||||
return masked
|
||||
|
||||
|
||||
def _is_fixed_provider(provider_type: str | None) -> bool:
|
||||
"""Whether this provider_type is managed by fixed-provider templates."""
|
||||
normalized = (provider_type or "custom").strip().lower()
|
||||
if normalized == ProviderType.CUSTOM.value:
|
||||
return False
|
||||
try:
|
||||
return ProviderType(normalized) in FIXED_PROVIDERS
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
@router.get("/providers/{provider_id}/endpoints", response_model=list[ProviderEndpointResponse])
|
||||
async def list_provider_endpoints(
|
||||
provider_id: str,
|
||||
request: Request,
|
||||
skip: int = Query(0, ge=0, description="跳过的记录数"),
|
||||
limit: int = Query(100, ge=1, le=1000, description="返回的最大记录数"),
|
||||
db: Session = Depends(get_db),
|
||||
) -> list[ProviderEndpointResponse]:
|
||||
"""
|
||||
获取指定 Provider 的所有 Endpoints
|
||||
|
||||
获取指定 Provider 下的所有 Endpoint 列表,包括配置、统计信息等。
|
||||
结果按创建时间倒序排列。
|
||||
|
||||
**路径参数**:
|
||||
- `provider_id`: Provider ID
|
||||
|
||||
**查询参数**:
|
||||
- `skip`: 跳过的记录数,用于分页(默认 0)
|
||||
- `limit`: 返回的最大记录数(1-1000,默认 100)
|
||||
|
||||
**返回字段**:
|
||||
- `id`: Endpoint ID
|
||||
- `provider_id`: Provider ID
|
||||
- `provider_name`: Provider 名称
|
||||
- `api_format`: API 格式
|
||||
- `base_url`: 基础 URL
|
||||
- `custom_path`: 自定义路径
|
||||
- `max_retries`: 最大重试次数
|
||||
- `is_active`: 是否活跃
|
||||
- `total_keys`: Key 总数
|
||||
- `active_keys`: 活跃 Key 数量
|
||||
- `proxy`: 代理配置(密码已脱敏)
|
||||
- 其他配置字段
|
||||
"""
|
||||
adapter = AdminListProviderEndpointsAdapter(
|
||||
provider_id=provider_id,
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
)
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
|
||||
@router.post("/providers/{provider_id}/endpoints", response_model=ProviderEndpointResponse)
|
||||
async def create_provider_endpoint(
|
||||
provider_id: str,
|
||||
endpoint_data: ProviderEndpointCreate,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
) -> ProviderEndpointResponse:
|
||||
"""
|
||||
为 Provider 创建新的 Endpoint
|
||||
|
||||
为指定 Provider 创建新的 Endpoint,每个 Provider 的每种 API 格式
|
||||
只能创建一个 Endpoint。
|
||||
|
||||
**路径参数**:
|
||||
- `provider_id`: Provider ID
|
||||
|
||||
**请求体字段**:
|
||||
- `provider_id`: Provider ID(必须与路径参数一致)
|
||||
- `api_format`: API 格式(如 claude、openai、gemini 等)
|
||||
- `base_url`: 基础 URL
|
||||
- `custom_path`: 自定义路径(可选)
|
||||
- `header_rules`: 请求头规则列表(可选,支持 set/drop/rename 操作)
|
||||
- `max_retries`: 最大重试次数(默认 2)
|
||||
- `config`: 额外配置(可选)
|
||||
- `proxy`: 代理配置(可选)
|
||||
|
||||
**返回字段**:
|
||||
- 包含完整的 Endpoint 信息
|
||||
"""
|
||||
adapter = AdminCreateProviderEndpointAdapter(
|
||||
provider_id=provider_id,
|
||||
endpoint_data=endpoint_data,
|
||||
)
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
|
||||
@router.get("/defaults/{api_format}/body-rules")
|
||||
async def get_default_endpoint_body_rules(
|
||||
api_format: str,
|
||||
request: Request,
|
||||
provider_type: str | None = None,
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict[str, Any]:
|
||||
"""获取指定 endpoint signature 的默认 body_rules。"""
|
||||
adapter = AdminGetDefaultBodyRulesAdapter(
|
||||
api_format=api_format, provider_type=provider_type or None
|
||||
)
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
|
||||
@router.get("/{endpoint_id}", response_model=ProviderEndpointResponse)
|
||||
async def get_endpoint(
|
||||
endpoint_id: str,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
) -> ProviderEndpointResponse:
|
||||
"""
|
||||
获取 Endpoint 详情
|
||||
|
||||
获取指定 Endpoint 的详细信息,包括配置、统计信息等。
|
||||
|
||||
**路径参数**:
|
||||
- `endpoint_id`: Endpoint ID
|
||||
|
||||
**返回字段**:
|
||||
- `id`: Endpoint ID
|
||||
- `provider_id`: Provider ID
|
||||
- `provider_name`: Provider 名称
|
||||
- `api_format`: API 格式
|
||||
- `base_url`: 基础 URL
|
||||
- `custom_path`: 自定义路径
|
||||
- `max_retries`: 最大重试次数
|
||||
- `is_active`: 是否活跃
|
||||
- `total_keys`: Key 总数
|
||||
- `active_keys`: 活跃 Key 数量
|
||||
- `proxy`: 代理配置(密码已脱敏)
|
||||
- 其他配置字段
|
||||
"""
|
||||
adapter = AdminGetProviderEndpointAdapter(endpoint_id=endpoint_id)
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
|
||||
@router.put("/{endpoint_id}", response_model=ProviderEndpointResponse)
|
||||
async def update_endpoint(
|
||||
endpoint_id: str,
|
||||
endpoint_data: ProviderEndpointUpdate,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
) -> ProviderEndpointResponse:
|
||||
"""
|
||||
更新 Endpoint
|
||||
|
||||
更新指定 Endpoint 的配置。支持部分更新。
|
||||
|
||||
**路径参数**:
|
||||
- `endpoint_id`: Endpoint ID
|
||||
|
||||
**请求体字段**(均为可选):
|
||||
- `base_url`: 基础 URL
|
||||
- `custom_path`: 自定义路径
|
||||
- `header_rules`: 请求头规则列表
|
||||
- `max_retries`: 最大重试次数
|
||||
- `is_active`: 是否活跃
|
||||
- `config`: 额外配置
|
||||
- `proxy`: 代理配置(设置为 null 可清除代理)
|
||||
|
||||
**返回字段**:
|
||||
- 包含更新后的完整 Endpoint 信息
|
||||
"""
|
||||
adapter = AdminUpdateProviderEndpointAdapter(
|
||||
endpoint_id=endpoint_id,
|
||||
endpoint_data=endpoint_data,
|
||||
)
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
|
||||
@router.delete("/{endpoint_id}")
|
||||
async def delete_endpoint(
|
||||
endpoint_id: str,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
"""
|
||||
删除 Endpoint
|
||||
|
||||
删除指定的 Endpoint,会影响该 Provider 在该 API 格式下的路由能力。
|
||||
Key 不会被删除,但包含该 API 格式的 Key 将无法被调度使用(直到重新创建该格式的 Endpoint)。
|
||||
|
||||
**路径参数**:
|
||||
- `endpoint_id`: Endpoint ID
|
||||
|
||||
**返回字段**:
|
||||
- `message`: 操作结果消息
|
||||
- `affected_keys_count`: 受影响的 Key 数量(包含该 API 格式)
|
||||
"""
|
||||
adapter = AdminDeleteProviderEndpointAdapter(endpoint_id=endpoint_id)
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
|
||||
# -------- Adapters --------
|
||||
|
||||
|
||||
@dataclass
|
||||
class AdminListProviderEndpointsAdapter(AdminApiAdapter):
|
||||
provider_id: str
|
||||
skip: int
|
||||
limit: int
|
||||
|
||||
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
||||
db = context.db
|
||||
provider = db.query(Provider).filter(Provider.id == self.provider_id).first()
|
||||
if not provider:
|
||||
raise NotFoundException(f"Provider {self.provider_id} 不存在")
|
||||
|
||||
endpoints = (
|
||||
db.query(ProviderEndpoint)
|
||||
.filter(ProviderEndpoint.provider_id == self.provider_id)
|
||||
.order_by(ProviderEndpoint.created_at.desc())
|
||||
.offset(self.skip)
|
||||
.limit(self.limit)
|
||||
.all()
|
||||
)
|
||||
|
||||
# Key 是 Provider 级别资源:按 key.api_formats 归类到各 Endpoint.api_format 下
|
||||
keys = (
|
||||
db.query(ProviderAPIKey.api_formats, ProviderAPIKey.is_active)
|
||||
.filter(ProviderAPIKey.provider_id == self.provider_id)
|
||||
.all()
|
||||
)
|
||||
total_keys_map: dict[str, int] = {}
|
||||
active_keys_map: dict[str, int] = {}
|
||||
for api_formats, is_active in keys:
|
||||
for fmt in api_formats or []:
|
||||
total_keys_map[fmt] = total_keys_map.get(fmt, 0) + 1
|
||||
if is_active:
|
||||
active_keys_map[fmt] = active_keys_map.get(fmt, 0) + 1
|
||||
|
||||
result: list[ProviderEndpointResponse] = []
|
||||
for endpoint in endpoints:
|
||||
endpoint_format = (
|
||||
endpoint.api_format
|
||||
if isinstance(endpoint.api_format, str)
|
||||
else endpoint.api_format.value
|
||||
)
|
||||
endpoint_dict = {
|
||||
**endpoint.__dict__,
|
||||
"provider_name": provider.name,
|
||||
"api_format": endpoint.api_format,
|
||||
"total_keys": total_keys_map.get(endpoint_format, 0),
|
||||
"active_keys": active_keys_map.get(endpoint_format, 0),
|
||||
"proxy": mask_proxy_password(endpoint.proxy),
|
||||
}
|
||||
endpoint_dict.pop("_sa_instance_state", None)
|
||||
result.append(ProviderEndpointResponse(**endpoint_dict))
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@dataclass
|
||||
class AdminCreateProviderEndpointAdapter(AdminApiAdapter):
|
||||
provider_id: str
|
||||
endpoint_data: ProviderEndpointCreate
|
||||
|
||||
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
||||
db = context.db
|
||||
provider = db.query(Provider).filter(Provider.id == self.provider_id).first()
|
||||
if not provider:
|
||||
raise NotFoundException(f"Provider {self.provider_id} 不存在")
|
||||
|
||||
# 固定类型 Provider:禁止通过该接口新增 Endpoints(端点由模板自动创建并锁定)
|
||||
provider_type = getattr(provider, "provider_type", None) or "custom"
|
||||
if _is_fixed_provider(provider_type):
|
||||
raise InvalidRequestException("固定类型 Provider 不允许手动新增 Endpoint")
|
||||
|
||||
if self.endpoint_data.provider_id != self.provider_id:
|
||||
raise InvalidRequestException("provider_id 不匹配")
|
||||
|
||||
existing = (
|
||||
db.query(ProviderEndpoint)
|
||||
.filter(
|
||||
and_(
|
||||
ProviderEndpoint.provider_id == self.provider_id,
|
||||
ProviderEndpoint.api_format == self.endpoint_data.api_format,
|
||||
)
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if existing:
|
||||
raise InvalidRequestException(
|
||||
f"Provider {provider.name} 已存在 {self.endpoint_data.api_format} 格式的 Endpoint"
|
||||
)
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
sig = parse_signature_key(self.endpoint_data.api_format)
|
||||
api_family = sig.api_family.value
|
||||
endpoint_kind = sig.endpoint_kind.value
|
||||
# 使用归一化后的 signature key,确保格式一致性
|
||||
normalized_api_format = sig.key
|
||||
body_rules = self.endpoint_data.body_rules
|
||||
if body_rules is None:
|
||||
body_rules = (
|
||||
get_default_body_rules_for_endpoint(
|
||||
normalized_api_format, provider_type=provider_type
|
||||
)
|
||||
or None
|
||||
)
|
||||
|
||||
new_endpoint = ProviderEndpoint(
|
||||
id=str(uuid.uuid4()),
|
||||
provider_id=self.provider_id,
|
||||
api_format=normalized_api_format,
|
||||
api_family=api_family,
|
||||
endpoint_kind=endpoint_kind,
|
||||
base_url=self.endpoint_data.base_url,
|
||||
custom_path=self.endpoint_data.custom_path,
|
||||
header_rules=self.endpoint_data.header_rules,
|
||||
body_rules=body_rules,
|
||||
max_retries=self.endpoint_data.max_retries,
|
||||
is_active=True,
|
||||
config=self.endpoint_data.config,
|
||||
proxy=self.endpoint_data.proxy.model_dump() if self.endpoint_data.proxy else None,
|
||||
format_acceptance_config=self.endpoint_data.format_acceptance_config,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
|
||||
db.add(new_endpoint)
|
||||
db.commit()
|
||||
db.refresh(new_endpoint)
|
||||
|
||||
# 清除 /v1/models 列表缓存
|
||||
await invalidate_models_list_cache()
|
||||
|
||||
logger.info(
|
||||
f"[OK] 创建 Endpoint: Provider={provider.name}, Format={self.endpoint_data.api_format}, ID={new_endpoint.id}"
|
||||
)
|
||||
|
||||
endpoint_dict = {
|
||||
k: v
|
||||
for k, v in new_endpoint.__dict__.items()
|
||||
if k not in {"api_format", "_sa_instance_state", "proxy"}
|
||||
}
|
||||
return ProviderEndpointResponse(
|
||||
**endpoint_dict,
|
||||
provider_name=provider.name,
|
||||
api_format=new_endpoint.api_format,
|
||||
proxy=mask_proxy_password(new_endpoint.proxy),
|
||||
total_keys=0,
|
||||
active_keys=0,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class AdminGetProviderEndpointAdapter(AdminApiAdapter):
|
||||
endpoint_id: str
|
||||
|
||||
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
||||
db = context.db
|
||||
endpoint = (
|
||||
db.query(ProviderEndpoint, Provider)
|
||||
.join(Provider, ProviderEndpoint.provider_id == Provider.id)
|
||||
.filter(ProviderEndpoint.id == self.endpoint_id)
|
||||
.first()
|
||||
)
|
||||
if not endpoint:
|
||||
raise NotFoundException(f"Endpoint {self.endpoint_id} 不存在")
|
||||
|
||||
endpoint_obj, provider = endpoint
|
||||
endpoint_format = (
|
||||
endpoint_obj.api_format
|
||||
if isinstance(endpoint_obj.api_format, str)
|
||||
else endpoint_obj.api_format.value
|
||||
)
|
||||
keys = (
|
||||
db.query(ProviderAPIKey.api_formats, ProviderAPIKey.is_active)
|
||||
.filter(ProviderAPIKey.provider_id == endpoint_obj.provider_id)
|
||||
.all()
|
||||
)
|
||||
total_keys = 0
|
||||
active_keys = 0
|
||||
for api_formats, is_active in keys:
|
||||
if endpoint_format in (api_formats or []):
|
||||
total_keys += 1
|
||||
if is_active:
|
||||
active_keys += 1
|
||||
|
||||
endpoint_dict = {
|
||||
k: v
|
||||
for k, v in endpoint_obj.__dict__.items()
|
||||
if k not in {"api_format", "_sa_instance_state", "proxy"}
|
||||
}
|
||||
return ProviderEndpointResponse(
|
||||
**endpoint_dict,
|
||||
provider_name=provider.name,
|
||||
api_format=endpoint_obj.api_format,
|
||||
proxy=mask_proxy_password(endpoint_obj.proxy),
|
||||
total_keys=total_keys,
|
||||
active_keys=active_keys,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class AdminUpdateProviderEndpointAdapter(AdminApiAdapter):
|
||||
endpoint_id: str
|
||||
endpoint_data: ProviderEndpointUpdate
|
||||
|
||||
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
||||
db = context.db
|
||||
endpoint = (
|
||||
db.query(ProviderEndpoint).filter(ProviderEndpoint.id == self.endpoint_id).first()
|
||||
)
|
||||
if not endpoint:
|
||||
raise NotFoundException(f"Endpoint {self.endpoint_id} 不存在")
|
||||
|
||||
update_data = self.endpoint_data.model_dump(exclude_unset=True)
|
||||
|
||||
# 固定类型 Provider 的 endpoint:锁定 base_url/custom_path(前端禁用仅是 UX,后端必须强校验)
|
||||
provider = db.query(Provider).filter(Provider.id == endpoint.provider_id).first()
|
||||
if provider:
|
||||
provider_type = getattr(provider, "provider_type", "custom")
|
||||
if _is_fixed_provider(provider_type):
|
||||
if "base_url" in update_data or "custom_path" in update_data:
|
||||
raise InvalidRequestException(
|
||||
"固定类型 Provider 的 Endpoint 不允许修改 base_url/custom_path"
|
||||
)
|
||||
normalized_provider_type = str(provider_type or "custom").strip().lower()
|
||||
endpoint_sig = str(getattr(endpoint, "api_format", "") or "").strip().lower()
|
||||
if (
|
||||
normalized_provider_type == ProviderType.CODEX.value
|
||||
and endpoint_sig == "openai:cli"
|
||||
):
|
||||
has_config_in_payload = "config" in update_data
|
||||
cfg_payload = (
|
||||
update_data.get("config")
|
||||
if has_config_in_payload
|
||||
else getattr(endpoint, "config", None)
|
||||
)
|
||||
cfg = dict(cfg_payload) if isinstance(cfg_payload, dict) else {}
|
||||
requested = (
|
||||
cfg.get("upstream_stream_policy")
|
||||
or cfg.get("upstreamStreamPolicy")
|
||||
or cfg.get("upstream_stream")
|
||||
)
|
||||
if (
|
||||
has_config_in_payload
|
||||
and requested is not None
|
||||
and parse_upstream_stream_policy(requested)
|
||||
!= UpstreamStreamPolicy.FORCE_STREAM
|
||||
):
|
||||
raise InvalidRequestException(
|
||||
"Codex OpenAI CLI 端点固定为强制流式,不允许修改"
|
||||
)
|
||||
cfg.pop("upstreamStreamPolicy", None)
|
||||
cfg.pop("upstream_stream", None)
|
||||
cfg["upstream_stream_policy"] = "force_stream"
|
||||
update_data["config"] = cfg
|
||||
|
||||
# 把 proxy 转换为 dict 存储,支持显式设置为 None 清除代理
|
||||
if "proxy" in update_data:
|
||||
if update_data["proxy"] is not None:
|
||||
new_proxy = dict(update_data["proxy"])
|
||||
# 只有当密码字段未提供时才保留原密码(空字符串视为显式清除)
|
||||
if "password" not in new_proxy and endpoint.proxy:
|
||||
old_password = endpoint.proxy.get("password")
|
||||
if old_password:
|
||||
new_proxy["password"] = old_password
|
||||
update_data["proxy"] = new_proxy
|
||||
# proxy 为 None 时保留,用于清除代理配置
|
||||
|
||||
# JSON 列需要 flag_modified 以确保 SQLAlchemy 检测到变更
|
||||
json_fields = {"header_rules", "body_rules", "config", "proxy", "format_acceptance_config"}
|
||||
|
||||
for field, value in update_data.items():
|
||||
setattr(endpoint, field, value)
|
||||
if field in json_fields:
|
||||
flag_modified(endpoint, field)
|
||||
|
||||
# Phase 3/4: 自动维护新架构字段,确保新增/历史数据都能被调度器按 family/kind 查询
|
||||
sig = parse_signature_key(endpoint.api_format)
|
||||
endpoint.api_family = sig.api_family.value
|
||||
endpoint.endpoint_kind = sig.endpoint_kind.value
|
||||
endpoint.updated_at = datetime.now(timezone.utc)
|
||||
|
||||
db.commit()
|
||||
db.refresh(endpoint)
|
||||
|
||||
# 清除 /v1/models 列表缓存(is_active 变更会影响模型可用性)
|
||||
await invalidate_models_list_cache()
|
||||
|
||||
provider = db.query(Provider).filter(Provider.id == endpoint.provider_id).first()
|
||||
logger.info(
|
||||
f"[OK] 更新 Endpoint: ID={self.endpoint_id}, Updates={list(update_data.keys())}"
|
||||
)
|
||||
|
||||
endpoint_format = (
|
||||
endpoint.api_format
|
||||
if isinstance(endpoint.api_format, str)
|
||||
else endpoint.api_format.value
|
||||
)
|
||||
keys = (
|
||||
db.query(ProviderAPIKey.api_formats, ProviderAPIKey.is_active)
|
||||
.filter(ProviderAPIKey.provider_id == endpoint.provider_id)
|
||||
.all()
|
||||
)
|
||||
total_keys = 0
|
||||
active_keys = 0
|
||||
for api_formats, is_active in keys:
|
||||
if endpoint_format in (api_formats or []):
|
||||
total_keys += 1
|
||||
if is_active:
|
||||
active_keys += 1
|
||||
|
||||
endpoint_dict = {
|
||||
k: v
|
||||
for k, v in endpoint.__dict__.items()
|
||||
if k not in {"api_format", "_sa_instance_state", "proxy"}
|
||||
}
|
||||
return ProviderEndpointResponse(
|
||||
**endpoint_dict,
|
||||
provider_name=provider.name if provider else "Unknown",
|
||||
api_format=endpoint.api_format,
|
||||
proxy=mask_proxy_password(endpoint.proxy),
|
||||
total_keys=total_keys,
|
||||
active_keys=active_keys,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class AdminDeleteProviderEndpointAdapter(AdminApiAdapter):
|
||||
endpoint_id: str
|
||||
|
||||
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
||||
db = context.db
|
||||
endpoint = (
|
||||
db.query(ProviderEndpoint).filter(ProviderEndpoint.id == self.endpoint_id).first()
|
||||
)
|
||||
if not endpoint:
|
||||
raise NotFoundException(f"Endpoint {self.endpoint_id} 不存在")
|
||||
|
||||
endpoint_format = (
|
||||
endpoint.api_format
|
||||
if isinstance(endpoint.api_format, str)
|
||||
else endpoint.api_format.value
|
||||
)
|
||||
|
||||
# 查询包含该格式的所有 Key,并从 api_formats 中移除该格式
|
||||
keys = (
|
||||
db.query(ProviderAPIKey)
|
||||
.filter(ProviderAPIKey.provider_id == endpoint.provider_id)
|
||||
.all()
|
||||
)
|
||||
affected_keys_count = 0
|
||||
for key in keys:
|
||||
if key.api_formats and endpoint_format in key.api_formats:
|
||||
affected_keys_count += 1
|
||||
# 移除该格式
|
||||
new_formats = [f for f in key.api_formats if f != endpoint_format]
|
||||
key.api_formats = new_formats if new_formats else []
|
||||
flag_modified(key, "api_formats")
|
||||
|
||||
db.delete(endpoint)
|
||||
db.commit()
|
||||
|
||||
# 清除 /v1/models 列表缓存
|
||||
await invalidate_models_list_cache()
|
||||
|
||||
logger.warning(
|
||||
f"[DELETE] 删除 Endpoint: ID={self.endpoint_id}, Format={endpoint_format}, "
|
||||
f"AffectedKeys={affected_keys_count}"
|
||||
)
|
||||
|
||||
return {
|
||||
"message": f"Endpoint {self.endpoint_id} 已删除",
|
||||
"affected_keys_count": affected_keys_count,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class AdminGetDefaultBodyRulesAdapter(AdminApiAdapter):
|
||||
api_format: str
|
||||
provider_type: str | None = None
|
||||
|
||||
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
||||
try:
|
||||
normalized_api_format = parse_signature_key(self.api_format).key
|
||||
except Exception as exc:
|
||||
raise InvalidRequestException(f"无效的 api_format: {self.api_format}") from exc
|
||||
|
||||
return {
|
||||
"api_format": normalized_api_format,
|
||||
"body_rules": get_default_body_rules_for_endpoint(
|
||||
normalized_api_format, provider_type=self.provider_type
|
||||
),
|
||||
}
|
||||
383
_deprecated_py_src/api/admin/gemini_files.py
Normal file
383
_deprecated_py_src/api/admin/gemini_files.py
Normal file
@@ -0,0 +1,383 @@
|
||||
"""
|
||||
Gemini Files 管理 API
|
||||
|
||||
提供文件映射管理与能力查询;上传入口已收成 Rust-only 兼容壳。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, Query, Request, UploadFile
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import delete, func
|
||||
from sqlalchemy.orm import Session, load_only
|
||||
|
||||
from src.api.base.admin_adapter import AdminApiAdapter
|
||||
from src.api.base.context import ApiRequestContext
|
||||
from src.api.base.pipeline import get_pipeline
|
||||
from src.database import get_db
|
||||
from src.models.database import GeminiFileMapping, ProviderAPIKey, User
|
||||
from src.services.gemini_files_mapping import delete_file_key_mapping
|
||||
|
||||
router = APIRouter(prefix="/api/admin/gemini-files", tags=["Gemini Files Management"])
|
||||
pipeline = get_pipeline()
|
||||
|
||||
_RUST_UPLOADER_DETAIL = "Admin Gemini file upload requires Rust uploader"
|
||||
|
||||
|
||||
class FileMappingResponse(BaseModel):
|
||||
id: str
|
||||
file_name: str
|
||||
key_id: str
|
||||
key_name: str | None = None
|
||||
user_id: str | None = None
|
||||
username: str | None = None
|
||||
display_name: str | None = None
|
||||
mime_type: str | None = None
|
||||
created_at: datetime
|
||||
expires_at: datetime
|
||||
is_expired: bool
|
||||
|
||||
|
||||
class FileMappingListResponse(BaseModel):
|
||||
items: list[FileMappingResponse]
|
||||
total: int
|
||||
page: int
|
||||
page_size: int
|
||||
|
||||
|
||||
class FileMappingStatsResponse(BaseModel):
|
||||
total_mappings: int
|
||||
active_mappings: int
|
||||
expired_mappings: int
|
||||
by_mime_type: dict[str, int]
|
||||
capable_keys_count: int
|
||||
|
||||
|
||||
class CapableKeyResponse(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
provider_name: str | None = None
|
||||
|
||||
|
||||
class UploadResultItem(BaseModel):
|
||||
key_id: str
|
||||
key_name: str | None = None
|
||||
success: bool
|
||||
file_name: str | None = None
|
||||
error: str | None = None
|
||||
|
||||
|
||||
class UploadResponse(BaseModel):
|
||||
display_name: str
|
||||
mime_type: str
|
||||
size_bytes: int
|
||||
results: list[UploadResultItem]
|
||||
success_count: int
|
||||
fail_count: int
|
||||
|
||||
|
||||
async def _list_file_mappings_response(
|
||||
*,
|
||||
db: Session,
|
||||
page: int,
|
||||
page_size: int,
|
||||
include_expired: bool,
|
||||
search: str | None,
|
||||
) -> FileMappingListResponse:
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
query = db.query(GeminiFileMapping)
|
||||
count_query = db.query(func.count(GeminiFileMapping.id))
|
||||
|
||||
if not include_expired:
|
||||
active_filter = GeminiFileMapping.expires_at > now
|
||||
query = query.filter(active_filter)
|
||||
count_query = count_query.filter(active_filter)
|
||||
|
||||
if search:
|
||||
search_pattern = f"%{search}%"
|
||||
search_filter = (GeminiFileMapping.file_name.ilike(search_pattern)) | (
|
||||
GeminiFileMapping.display_name.ilike(search_pattern)
|
||||
)
|
||||
query = query.filter(search_filter)
|
||||
count_query = count_query.filter(search_filter)
|
||||
|
||||
total = int(count_query.scalar() or 0)
|
||||
offset = (page - 1) * page_size
|
||||
mappings = (
|
||||
query.options(
|
||||
load_only(
|
||||
GeminiFileMapping.id,
|
||||
GeminiFileMapping.file_name,
|
||||
GeminiFileMapping.key_id,
|
||||
GeminiFileMapping.user_id,
|
||||
GeminiFileMapping.display_name,
|
||||
GeminiFileMapping.mime_type,
|
||||
GeminiFileMapping.created_at,
|
||||
GeminiFileMapping.expires_at,
|
||||
)
|
||||
)
|
||||
.order_by(GeminiFileMapping.created_at.desc())
|
||||
.offset(offset)
|
||||
.limit(page_size)
|
||||
.all()
|
||||
)
|
||||
|
||||
key_ids = {m.key_id for m in mappings}
|
||||
user_ids = {m.user_id for m in mappings if m.user_id}
|
||||
|
||||
keys_map: dict[str, str | None] = {}
|
||||
if key_ids:
|
||||
keys = (
|
||||
db.query(ProviderAPIKey)
|
||||
.options(load_only(ProviderAPIKey.id, ProviderAPIKey.name))
|
||||
.filter(ProviderAPIKey.id.in_(key_ids))
|
||||
.all()
|
||||
)
|
||||
keys_map = {str(k.id): k.name for k in keys}
|
||||
|
||||
users_map: dict[str, str | None] = {}
|
||||
if user_ids:
|
||||
users = (
|
||||
db.query(User)
|
||||
.options(load_only(User.id, User.username))
|
||||
.filter(User.id.in_(user_ids))
|
||||
.all()
|
||||
)
|
||||
users_map = {str(u.id): u.username for u in users}
|
||||
|
||||
return FileMappingListResponse(
|
||||
items=[
|
||||
FileMappingResponse(
|
||||
id=str(m.id),
|
||||
file_name=m.file_name,
|
||||
key_id=str(m.key_id),
|
||||
key_name=keys_map.get(str(m.key_id)),
|
||||
user_id=str(m.user_id) if m.user_id else None,
|
||||
username=users_map.get(str(m.user_id)) if m.user_id else None,
|
||||
display_name=m.display_name,
|
||||
mime_type=m.mime_type,
|
||||
created_at=m.created_at,
|
||||
expires_at=m.expires_at,
|
||||
is_expired=m.expires_at <= now,
|
||||
)
|
||||
for m in mappings
|
||||
],
|
||||
total=total,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
)
|
||||
|
||||
|
||||
async def _get_file_mapping_stats_response(*, db: Session) -> FileMappingStatsResponse:
|
||||
now = datetime.now(timezone.utc)
|
||||
total_mappings = db.query(func.count(GeminiFileMapping.id)).scalar() or 0
|
||||
active_mappings = (
|
||||
db.query(func.count(GeminiFileMapping.id))
|
||||
.filter(GeminiFileMapping.expires_at > now)
|
||||
.scalar()
|
||||
or 0
|
||||
)
|
||||
expired_mappings = total_mappings - active_mappings
|
||||
mime_stats = (
|
||||
db.query(GeminiFileMapping.mime_type, func.count(GeminiFileMapping.id))
|
||||
.filter(GeminiFileMapping.expires_at > now)
|
||||
.group_by(GeminiFileMapping.mime_type)
|
||||
.all()
|
||||
)
|
||||
by_mime_type = {(mime_type or "unknown"): count for mime_type, count in mime_stats}
|
||||
keys = db.query(ProviderAPIKey.capabilities).filter(ProviderAPIKey.is_active.is_(True)).all()
|
||||
capable_keys_count = sum(
|
||||
1
|
||||
for (capabilities,) in keys
|
||||
if isinstance(capabilities, dict) and capabilities.get("gemini_files", False)
|
||||
)
|
||||
return FileMappingStatsResponse(
|
||||
total_mappings=total_mappings,
|
||||
active_mappings=active_mappings,
|
||||
expired_mappings=expired_mappings,
|
||||
by_mime_type=by_mime_type,
|
||||
capable_keys_count=capable_keys_count,
|
||||
)
|
||||
|
||||
|
||||
async def _delete_mapping_response(*, db: Session, mapping_id: str) -> dict[str, Any]:
|
||||
mapping = db.query(GeminiFileMapping).filter(GeminiFileMapping.id == mapping_id).first()
|
||||
if not mapping:
|
||||
raise HTTPException(status_code=404, detail="Mapping not found")
|
||||
file_name = mapping.file_name
|
||||
db.delete(mapping)
|
||||
db.commit()
|
||||
await delete_file_key_mapping(file_name)
|
||||
return {"message": "Mapping deleted successfully", "file_name": file_name}
|
||||
|
||||
|
||||
async def _cleanup_expired_mappings_response(*, db: Session) -> dict[str, Any]:
|
||||
now = datetime.now(timezone.utc)
|
||||
result = db.execute(delete(GeminiFileMapping).where(GeminiFileMapping.expires_at <= now))
|
||||
db.commit()
|
||||
deleted_count = result.rowcount
|
||||
return {
|
||||
"message": f"Cleaned up {deleted_count} expired mappings",
|
||||
"deleted_count": deleted_count,
|
||||
}
|
||||
|
||||
|
||||
async def _list_capable_keys_response(*, db: Session) -> list[CapableKeyResponse]:
|
||||
from src.models.database import Provider
|
||||
|
||||
key_rows = (
|
||||
db.query(
|
||||
ProviderAPIKey.id,
|
||||
ProviderAPIKey.name,
|
||||
ProviderAPIKey.provider_id,
|
||||
ProviderAPIKey.capabilities,
|
||||
)
|
||||
.filter(ProviderAPIKey.is_active.is_(True))
|
||||
.all()
|
||||
)
|
||||
capable_keys = [
|
||||
key
|
||||
for key in key_rows
|
||||
if isinstance(key.capabilities, dict) and key.capabilities.get("gemini_files", False)
|
||||
]
|
||||
|
||||
provider_ids = {key.provider_id for key in capable_keys if key.provider_id}
|
||||
provider_map: dict[str, str] = {}
|
||||
if provider_ids:
|
||||
providers = db.query(Provider.id, Provider.name).filter(Provider.id.in_(provider_ids)).all()
|
||||
provider_map = {str(provider_id): provider_name for provider_id, provider_name in providers}
|
||||
|
||||
return [
|
||||
CapableKeyResponse(
|
||||
id=str(key.id),
|
||||
name=key.name,
|
||||
provider_name=provider_map.get(str(key.provider_id)),
|
||||
)
|
||||
for key in capable_keys
|
||||
]
|
||||
|
||||
|
||||
async def _upload_file_response(*, file: UploadFile, key_ids: str) -> Any:
|
||||
del file, key_ids
|
||||
raise HTTPException(status_code=503, detail=_RUST_UPLOADER_DETAIL)
|
||||
|
||||
|
||||
@dataclass
|
||||
class AdminGeminiFilesListMappingsAdapter(AdminApiAdapter):
|
||||
page: int
|
||||
page_size: int
|
||||
include_expired: bool
|
||||
search: str | None
|
||||
|
||||
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
||||
return await _list_file_mappings_response(
|
||||
db=context.db,
|
||||
page=self.page,
|
||||
page_size=self.page_size,
|
||||
include_expired=self.include_expired,
|
||||
search=self.search,
|
||||
)
|
||||
|
||||
|
||||
class AdminGeminiFilesStatsAdapter(AdminApiAdapter):
|
||||
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
||||
return await _get_file_mapping_stats_response(db=context.db)
|
||||
|
||||
|
||||
@dataclass
|
||||
class AdminGeminiFilesDeleteMappingAdapter(AdminApiAdapter):
|
||||
mapping_id: str
|
||||
|
||||
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
||||
return await _delete_mapping_response(db=context.db, mapping_id=self.mapping_id)
|
||||
|
||||
|
||||
class AdminGeminiFilesCleanupMappingsAdapter(AdminApiAdapter):
|
||||
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
||||
return await _cleanup_expired_mappings_response(db=context.db)
|
||||
|
||||
|
||||
class AdminGeminiFilesCapableKeysAdapter(AdminApiAdapter):
|
||||
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
||||
return await _list_capable_keys_response(db=context.db)
|
||||
|
||||
|
||||
@dataclass
|
||||
class AdminGeminiFilesUploadAdapter(AdminApiAdapter):
|
||||
file: UploadFile
|
||||
key_ids: str
|
||||
|
||||
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
||||
del context
|
||||
return await _upload_file_response(file=self.file, key_ids=self.key_ids)
|
||||
|
||||
|
||||
@router.get("/mappings", response_model=FileMappingListResponse)
|
||||
async def list_file_mappings(
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=1, le=100),
|
||||
include_expired: bool = Query(False),
|
||||
search: str | None = Query(None),
|
||||
) -> Any:
|
||||
adapter = AdminGeminiFilesListMappingsAdapter(
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
include_expired=include_expired,
|
||||
search=search,
|
||||
)
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
|
||||
@router.get("/stats", response_model=FileMappingStatsResponse)
|
||||
async def get_file_mapping_stats(
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
) -> Any:
|
||||
adapter = AdminGeminiFilesStatsAdapter()
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
|
||||
@router.delete("/mappings/{mapping_id}")
|
||||
async def delete_mapping(
|
||||
mapping_id: str,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
) -> Any:
|
||||
adapter = AdminGeminiFilesDeleteMappingAdapter(mapping_id=mapping_id)
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
|
||||
@router.delete("/mappings")
|
||||
async def cleanup_expired_mappings(
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
) -> Any:
|
||||
adapter = AdminGeminiFilesCleanupMappingsAdapter()
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
|
||||
@router.get("/capable-keys", response_model=list[CapableKeyResponse])
|
||||
async def list_capable_keys(
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
) -> Any:
|
||||
adapter = AdminGeminiFilesCapableKeysAdapter()
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
|
||||
@router.post("/upload", response_model=UploadResponse)
|
||||
async def upload_file(
|
||||
request: Request,
|
||||
file: UploadFile = File(...),
|
||||
key_ids: str = Query(..., description="逗号分隔的 Key ID 列表"),
|
||||
db: Session = Depends(get_db),
|
||||
) -> Any:
|
||||
adapter = AdminGeminiFilesUploadAdapter(file=file, key_ids=key_ids)
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
504
_deprecated_py_src/api/admin/ldap.py
Normal file
504
_deprecated_py_src/api/admin/ldap.py
Normal file
@@ -0,0 +1,504 @@
|
||||
"""LDAP配置管理API端点。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from pydantic import BaseModel, Field, ValidationError, field_validator
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from src.api.base.admin_adapter import AdminApiAdapter
|
||||
from src.api.base.context import ApiRequestContext
|
||||
from src.api.base.pipeline import get_pipeline
|
||||
from src.core.crypto import crypto_service
|
||||
from src.core.enums import AuthSource
|
||||
from src.core.exceptions import InvalidRequestException, translate_pydantic_error
|
||||
from src.core.logger import logger
|
||||
from src.database import get_db
|
||||
from src.models.database import AuditEventType, LDAPConfig, User, UserRole
|
||||
from src.services.system.audit import AuditService
|
||||
|
||||
router = APIRouter(prefix="/api/admin/ldap", tags=["Admin - LDAP"])
|
||||
pipeline = get_pipeline()
|
||||
|
||||
# bcrypt 哈希格式正则:$2a$, $2b$, $2y$ + 2位cost + $ + 53字符(22位salt + 31位hash)
|
||||
BCRYPT_HASH_PATTERN = re.compile(r"^\$2[aby]\$\d{2}\$.{53}$")
|
||||
|
||||
|
||||
# ========== Request/Response Models ==========
|
||||
|
||||
|
||||
class LDAPConfigResponse(BaseModel):
|
||||
"""LDAP配置响应(不返回密码)"""
|
||||
|
||||
server_url: str | None = None
|
||||
bind_dn: str | None = None
|
||||
base_dn: str | None = None
|
||||
has_bind_password: bool = False
|
||||
user_search_filter: str
|
||||
username_attr: str
|
||||
email_attr: str
|
||||
display_name_attr: str
|
||||
is_enabled: bool
|
||||
is_exclusive: bool
|
||||
use_starttls: bool
|
||||
connect_timeout: int
|
||||
|
||||
|
||||
class LDAPConfigUpdate(BaseModel):
|
||||
"""LDAP配置更新请求"""
|
||||
|
||||
server_url: str = Field(..., min_length=1, max_length=255)
|
||||
bind_dn: str = Field(..., min_length=1, max_length=255)
|
||||
# 允许空字符串表示"清除密码";非空时自动 strip 并校验不能为空
|
||||
bind_password: str | None = Field(None, max_length=1024)
|
||||
base_dn: str = Field(..., min_length=1, max_length=255)
|
||||
user_search_filter: str = Field(default="(uid={username})", max_length=500)
|
||||
username_attr: str = Field(default="uid", max_length=50)
|
||||
email_attr: str = Field(default="mail", max_length=50)
|
||||
display_name_attr: str = Field(default="cn", max_length=50)
|
||||
is_enabled: bool = False
|
||||
is_exclusive: bool = False
|
||||
use_starttls: bool = False
|
||||
connect_timeout: int = Field(default=10, ge=1, le=60) # 单次操作超时,跨国网络建议 15-30 秒
|
||||
|
||||
@field_validator("bind_password")
|
||||
@classmethod
|
||||
def validate_bind_password(cls, v: str | None) -> str | None:
|
||||
if v is None or v == "":
|
||||
return v
|
||||
v = v.strip()
|
||||
if not v:
|
||||
raise ValueError("绑定密码不能为空")
|
||||
return v
|
||||
|
||||
@field_validator("user_search_filter")
|
||||
@classmethod
|
||||
def validate_search_filter(cls, v: str) -> str:
|
||||
if "{username}" not in v:
|
||||
raise ValueError("搜索过滤器必须包含 {username} 占位符")
|
||||
# 验证括号匹配和嵌套正确性
|
||||
depth = 0
|
||||
for char in v:
|
||||
if char == "(":
|
||||
depth += 1
|
||||
elif char == ")":
|
||||
depth -= 1
|
||||
if depth < 0:
|
||||
raise ValueError("搜索过滤器括号不匹配")
|
||||
if depth != 0:
|
||||
raise ValueError("搜索过滤器括号不匹配")
|
||||
# 限制过滤器复杂度,防止构造复杂查询
|
||||
# 检查嵌套层数而非括号总数
|
||||
depth = 0
|
||||
max_depth = 0
|
||||
for char in v:
|
||||
if char == "(":
|
||||
depth += 1
|
||||
max_depth = max(max_depth, depth)
|
||||
elif char == ")":
|
||||
depth -= 1
|
||||
if max_depth > 5:
|
||||
raise ValueError("搜索过滤器嵌套层数过深(最多5层)")
|
||||
if len(v) > 200:
|
||||
raise ValueError("搜索过滤器过长(最多200字符)")
|
||||
return v
|
||||
|
||||
|
||||
class LDAPTestResponse(BaseModel):
|
||||
"""LDAP连接测试响应"""
|
||||
|
||||
success: bool
|
||||
message: str
|
||||
|
||||
|
||||
class LDAPConfigTest(BaseModel):
|
||||
"""LDAP配置测试请求(全部可选,用于临时覆盖)"""
|
||||
|
||||
server_url: str | None = Field(None, min_length=1, max_length=255)
|
||||
bind_dn: str | None = Field(None, min_length=1, max_length=255)
|
||||
bind_password: str | None = Field(None, min_length=1)
|
||||
base_dn: str | None = Field(None, min_length=1, max_length=255)
|
||||
user_search_filter: str | None = Field(None, max_length=500)
|
||||
username_attr: str | None = Field(None, max_length=50)
|
||||
email_attr: str | None = Field(None, max_length=50)
|
||||
display_name_attr: str | None = Field(None, max_length=50)
|
||||
is_enabled: bool | None = None
|
||||
is_exclusive: bool | None = None
|
||||
use_starttls: bool | None = None
|
||||
connect_timeout: int | None = Field(None, ge=1, le=60)
|
||||
|
||||
@field_validator("user_search_filter")
|
||||
@classmethod
|
||||
def validate_search_filter(cls, v: str | None) -> str | None:
|
||||
if v is None:
|
||||
return v
|
||||
if "{username}" not in v:
|
||||
raise ValueError("搜索过滤器必须包含 {username} 占位符")
|
||||
# 验证括号匹配和嵌套正确性
|
||||
depth = 0
|
||||
for char in v:
|
||||
if char == "(":
|
||||
depth += 1
|
||||
elif char == ")":
|
||||
depth -= 1
|
||||
if depth < 0:
|
||||
raise ValueError("搜索过滤器括号不匹配")
|
||||
if depth != 0:
|
||||
raise ValueError("搜索过滤器括号不匹配")
|
||||
# 限制过滤器复杂度(检查嵌套层数而非括号总数)
|
||||
depth = 0
|
||||
max_depth = 0
|
||||
for char in v:
|
||||
if char == "(":
|
||||
depth += 1
|
||||
max_depth = max(max_depth, depth)
|
||||
elif char == ")":
|
||||
depth -= 1
|
||||
if max_depth > 5:
|
||||
raise ValueError("搜索过滤器嵌套层数过深(最多5层)")
|
||||
if len(v) > 200:
|
||||
raise ValueError("搜索过滤器过长(最多200字符)")
|
||||
return v
|
||||
|
||||
|
||||
# ========== API Endpoints ==========
|
||||
|
||||
|
||||
@router.get("/config")
|
||||
async def get_ldap_config(request: Request, db: Session = Depends(get_db)) -> Any:
|
||||
"""
|
||||
获取 LDAP 配置
|
||||
|
||||
获取系统当前的 LDAP 认证配置信息,用于管理界面显示和编辑。
|
||||
密码字段不会返回原文,仅返回是否已设置的标志。
|
||||
|
||||
**返回字段**:
|
||||
- `server_url`: LDAP 服务器地址(如:ldap://ldap.example.com:389)
|
||||
- `bind_dn`: 绑定 DN(如:cn=admin,dc=example,dc=com)
|
||||
- `base_dn`: 搜索基准 DN(如:ou=users,dc=example,dc=com)
|
||||
- `has_bind_password`: 是否已设置绑定密码(布尔值)
|
||||
- `user_search_filter`: 用户搜索过滤器(默认:(uid={username}))
|
||||
- `username_attr`: 用户名属性(默认:uid)
|
||||
- `email_attr`: 邮箱属性(默认:mail)
|
||||
- `display_name_attr`: 显示名称属性(默认:cn)
|
||||
- `is_enabled`: 是否启用 LDAP 认证
|
||||
- `is_exclusive`: 是否仅允许 LDAP 登录(独占模式)
|
||||
- `use_starttls`: 是否使用 STARTTLS 加密连接
|
||||
- `connect_timeout`: 连接超时时间(秒,1-60)
|
||||
"""
|
||||
adapter = AdminGetLDAPConfigAdapter()
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
|
||||
@router.put("/config")
|
||||
async def update_ldap_config(request: Request, db: Session = Depends(get_db)) -> Any:
|
||||
"""
|
||||
更新 LDAP 配置
|
||||
|
||||
更新系统的 LDAP 认证配置。支持完整配置更新,包括连接参数、
|
||||
搜索过滤器、属性映射等。提供多重安全校验,防止误锁定管理员。
|
||||
|
||||
**请求体字段**:
|
||||
- `server_url`: LDAP 服务器地址(必填,1-255字符)
|
||||
- `bind_dn`: 绑定 DN(必填,1-255字符)
|
||||
- `bind_password`: 绑定密码(可选,设为空字符串可清除密码)
|
||||
- `base_dn`: 搜索基准 DN(必填,1-255字符)
|
||||
- `user_search_filter`: 用户搜索过滤器(必须包含 {username} 占位符,默认:(uid={username}))
|
||||
- `username_attr`: 用户名属性(默认:uid)
|
||||
- `email_attr`: 邮箱属性(默认:mail)
|
||||
- `display_name_attr`: 显示名称属性(默认:cn)
|
||||
- `is_enabled`: 是否启用 LDAP 认证
|
||||
- `is_exclusive`: 是否仅允许 LDAP 登录(需先启用 LDAP)
|
||||
- `use_starttls`: 是否使用 STARTTLS 加密连接
|
||||
- `connect_timeout`: 连接超时时间(秒,1-60,默认 10)
|
||||
|
||||
**安全校验**:
|
||||
- 启用 LDAP 时必须设置有效的绑定密码
|
||||
- 启用独占模式前会检查是否有至少 1 个有效的本地管理员账户
|
||||
- 独占模式要求先启用 LDAP 认证
|
||||
- 搜索过滤器必须包含 {username} 占位符且括号匹配
|
||||
- 搜索过滤器嵌套层数不超过 5 层,长度不超过 200 字符
|
||||
|
||||
**返回字段**:
|
||||
- `message`: 操作结果消息
|
||||
"""
|
||||
adapter = AdminUpdateLDAPConfigAdapter()
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
|
||||
@router.post("/test")
|
||||
async def test_ldap_connection(request: Request, db: Session = Depends(get_db)) -> Any:
|
||||
"""
|
||||
测试 LDAP 连接
|
||||
|
||||
在保存配置前测试 LDAP 服务器连接是否正常。支持使用已保存的配置,
|
||||
也支持通过请求体覆盖任意配置项进行临时测试,而不影响已保存的配置。
|
||||
|
||||
**请求体字段**(均为可选,用于临时覆盖):
|
||||
- `server_url`: LDAP 服务器地址(覆盖已保存的配置)
|
||||
- `bind_dn`: 绑定 DN(覆盖已保存的配置)
|
||||
- `bind_password`: 绑定密码(覆盖已保存的密码)
|
||||
- `base_dn`: 搜索基准 DN(覆盖已保存的配置)
|
||||
- `user_search_filter`: 用户搜索过滤器(覆盖已保存的配置)
|
||||
- `username_attr`: 用户名属性(覆盖已保存的配置)
|
||||
- `email_attr`: 邮箱属性(覆盖已保存的配置)
|
||||
- `display_name_attr`: 显示名称属性(覆盖已保存的配置)
|
||||
- `use_starttls`: 是否使用 STARTTLS(覆盖已保存的配置)
|
||||
- `connect_timeout`: 连接超时时间(覆盖已保存的配置)
|
||||
|
||||
**测试逻辑**:
|
||||
- 未提供的字段使用已保存的配置值
|
||||
- `bind_password` 优先使用请求体中的值,否则使用已保存的加密密码
|
||||
- 测试时会尝试连接 LDAP 服务器并验证绑定 DN
|
||||
|
||||
**返回字段**:
|
||||
- `success`: 测试是否成功(布尔值)
|
||||
- `message`: 测试结果消息(成功或失败原因)
|
||||
"""
|
||||
adapter = AdminTestLDAPConnectionAdapter()
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
|
||||
# ========== Adapters ==========
|
||||
|
||||
|
||||
class AdminGetLDAPConfigAdapter(AdminApiAdapter):
|
||||
async def handle(self, context: ApiRequestContext) -> dict[str, Any]: # type: ignore[override]
|
||||
db = context.db
|
||||
config = db.query(LDAPConfig).first()
|
||||
|
||||
if not config:
|
||||
return LDAPConfigResponse(
|
||||
server_url=None,
|
||||
bind_dn=None,
|
||||
base_dn=None,
|
||||
has_bind_password=False,
|
||||
user_search_filter="(uid={username})",
|
||||
username_attr="uid",
|
||||
email_attr="mail",
|
||||
display_name_attr="cn",
|
||||
is_enabled=False,
|
||||
is_exclusive=False,
|
||||
use_starttls=False,
|
||||
connect_timeout=10,
|
||||
).model_dump()
|
||||
|
||||
return LDAPConfigResponse(
|
||||
server_url=config.server_url,
|
||||
bind_dn=config.bind_dn,
|
||||
base_dn=config.base_dn,
|
||||
has_bind_password=bool(config.bind_password_encrypted),
|
||||
user_search_filter=config.user_search_filter,
|
||||
username_attr=config.username_attr,
|
||||
email_attr=config.email_attr,
|
||||
display_name_attr=config.display_name_attr,
|
||||
is_enabled=config.is_enabled,
|
||||
is_exclusive=config.is_exclusive,
|
||||
use_starttls=config.use_starttls,
|
||||
connect_timeout=config.connect_timeout,
|
||||
).model_dump()
|
||||
|
||||
|
||||
class AdminUpdateLDAPConfigAdapter(AdminApiAdapter):
|
||||
async def handle(self, context: ApiRequestContext) -> dict[str, str]: # type: ignore[override]
|
||||
db = context.db
|
||||
payload = context.ensure_json_body()
|
||||
|
||||
try:
|
||||
config_update = LDAPConfigUpdate.model_validate(payload)
|
||||
except ValidationError as e:
|
||||
errors = e.errors()
|
||||
if errors:
|
||||
raise InvalidRequestException(translate_pydantic_error(errors[0]))
|
||||
raise InvalidRequestException("请求数据验证失败")
|
||||
|
||||
# 使用行级锁防止并发修改导致的竞态条件
|
||||
config = db.query(LDAPConfig).with_for_update().first()
|
||||
is_new_config = config is None
|
||||
|
||||
if is_new_config:
|
||||
# 首次创建配置时必须提供密码
|
||||
if not config_update.bind_password:
|
||||
raise InvalidRequestException("首次配置 LDAP 时必须设置绑定密码")
|
||||
config = LDAPConfig()
|
||||
db.add(config)
|
||||
|
||||
# 需要启用 LDAP 且未提交新密码时,验证已保存密码可解密(避免开启后不可用)
|
||||
if config_update.is_enabled and config_update.bind_password is None:
|
||||
try:
|
||||
if not config.get_bind_password():
|
||||
raise InvalidRequestException("启用 LDAP 认证 需要先设置绑定密码")
|
||||
except InvalidRequestException:
|
||||
raise
|
||||
except Exception:
|
||||
raise InvalidRequestException("绑定密码解密失败,请重新设置绑定密码")
|
||||
|
||||
# 计算更新后的密码状态(用于校验是否可启用/独占)
|
||||
if config_update.bind_password is None:
|
||||
will_have_password = bool(config.bind_password_encrypted)
|
||||
elif config_update.bind_password == "":
|
||||
will_have_password = False
|
||||
else:
|
||||
will_have_password = True
|
||||
|
||||
# 独占模式必须启用 LDAP 且必须有绑定密码(防止误锁定)
|
||||
if config_update.is_exclusive and not config_update.is_enabled:
|
||||
raise InvalidRequestException("仅允许 LDAP 登录 需要先启用 LDAP 认证")
|
||||
if config_update.is_enabled and not will_have_password:
|
||||
raise InvalidRequestException("启用 LDAP 认证 需要先设置绑定密码")
|
||||
if config_update.is_exclusive and not will_have_password:
|
||||
raise InvalidRequestException("仅允许 LDAP 登录 需要先设置绑定密码")
|
||||
|
||||
config.server_url = config_update.server_url
|
||||
config.bind_dn = config_update.bind_dn
|
||||
config.base_dn = config_update.base_dn
|
||||
config.user_search_filter = config_update.user_search_filter
|
||||
config.username_attr = config_update.username_attr
|
||||
config.email_attr = config_update.email_attr
|
||||
config.display_name_attr = config_update.display_name_attr
|
||||
config.is_enabled = config_update.is_enabled
|
||||
config.is_exclusive = config_update.is_exclusive
|
||||
config.use_starttls = config_update.use_starttls
|
||||
config.connect_timeout = config_update.connect_timeout
|
||||
|
||||
# 启用独占模式前检查是否有足够的本地管理员(防止锁定)
|
||||
# 使用 with_for_update() 阻塞锁防止竞态条件(移除 nowait 确保并发安全)
|
||||
if config_update.is_enabled and config_update.is_exclusive:
|
||||
local_admins = (
|
||||
db.query(User)
|
||||
.filter(
|
||||
User.role == UserRole.ADMIN,
|
||||
User.auth_source == AuthSource.LOCAL,
|
||||
User.is_active.is_(True),
|
||||
User.is_deleted.is_(False),
|
||||
)
|
||||
.with_for_update()
|
||||
.all()
|
||||
)
|
||||
# 验证至少有一个管理员有有效的密码哈希(可以登录)
|
||||
# 使用严格的 bcrypt 格式校验:$2a$/$2b$/$2y$ + 2位cost + $ + 53字符
|
||||
valid_admin_count = sum(
|
||||
1
|
||||
for admin in local_admins
|
||||
if admin.password_hash
|
||||
and isinstance(admin.password_hash, str)
|
||||
and BCRYPT_HASH_PATTERN.match(admin.password_hash)
|
||||
)
|
||||
if valid_admin_count < 1:
|
||||
raise InvalidRequestException(
|
||||
"启用 LDAP 独占模式前,必须至少保留 1 个有效的本地管理员账户(含有效密码)作为紧急恢复通道"
|
||||
)
|
||||
|
||||
if config_update.bind_password is not None:
|
||||
if config_update.bind_password == "":
|
||||
# 显式清除密码(设置为 NULL)
|
||||
config.bind_password_encrypted = None
|
||||
password_changed = "cleared"
|
||||
else:
|
||||
config.bind_password_encrypted = crypto_service.encrypt(config_update.bind_password)
|
||||
password_changed = "updated"
|
||||
else:
|
||||
password_changed = None
|
||||
|
||||
db.commit()
|
||||
|
||||
# 记录审计日志
|
||||
AuditService.log_event(
|
||||
db=db,
|
||||
event_type=AuditEventType.CONFIG_CHANGED,
|
||||
description=f"LDAP 配置已更新 (enabled={config_update.is_enabled}, exclusive={config_update.is_exclusive})",
|
||||
user_id=str(context.user.id) if context.user else None,
|
||||
metadata={
|
||||
"server_url": config_update.server_url,
|
||||
"is_enabled": config_update.is_enabled,
|
||||
"is_exclusive": config_update.is_exclusive,
|
||||
"password_changed": password_changed,
|
||||
"is_new_config": is_new_config,
|
||||
},
|
||||
)
|
||||
|
||||
return {"message": "LDAP配置更新成功"}
|
||||
|
||||
|
||||
class AdminTestLDAPConnectionAdapter(AdminApiAdapter):
|
||||
async def handle(self, context: ApiRequestContext) -> dict[str, Any]: # type: ignore[override]
|
||||
from src.services.auth.ldap import LDAPService
|
||||
|
||||
db = context.db
|
||||
if context.json_body is not None:
|
||||
payload = context.json_body
|
||||
elif not context.raw_body:
|
||||
payload = {}
|
||||
else:
|
||||
payload = context.ensure_json_body()
|
||||
|
||||
saved_config = db.query(LDAPConfig).first()
|
||||
|
||||
try:
|
||||
overrides = LDAPConfigTest.model_validate(payload)
|
||||
except ValidationError as e:
|
||||
errors = e.errors()
|
||||
if errors:
|
||||
raise InvalidRequestException(translate_pydantic_error(errors[0]))
|
||||
raise InvalidRequestException("请求数据验证失败")
|
||||
|
||||
config_data: dict[str, Any] = {}
|
||||
|
||||
if saved_config:
|
||||
config_data = {
|
||||
"server_url": saved_config.server_url,
|
||||
"bind_dn": saved_config.bind_dn,
|
||||
"base_dn": saved_config.base_dn,
|
||||
"user_search_filter": saved_config.user_search_filter,
|
||||
"username_attr": saved_config.username_attr,
|
||||
"email_attr": saved_config.email_attr,
|
||||
"display_name_attr": saved_config.display_name_attr,
|
||||
"use_starttls": saved_config.use_starttls,
|
||||
"connect_timeout": saved_config.connect_timeout,
|
||||
}
|
||||
|
||||
# 应用前端传入的覆盖值
|
||||
for field in [
|
||||
"server_url",
|
||||
"bind_dn",
|
||||
"base_dn",
|
||||
"user_search_filter",
|
||||
"username_attr",
|
||||
"email_attr",
|
||||
"display_name_attr",
|
||||
"use_starttls",
|
||||
"is_enabled",
|
||||
"is_exclusive",
|
||||
"connect_timeout",
|
||||
]:
|
||||
value = getattr(overrides, field)
|
||||
if value is not None:
|
||||
config_data[field] = value
|
||||
|
||||
# bind_password 优先使用 overrides;否则使用已保存的密码(允许保存密码无法解密时依然用 overrides 测试)
|
||||
if overrides.bind_password is not None:
|
||||
config_data["bind_password"] = overrides.bind_password
|
||||
elif saved_config and saved_config.bind_password_encrypted:
|
||||
try:
|
||||
config_data["bind_password"] = crypto_service.decrypt(
|
||||
saved_config.bind_password_encrypted
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"绑定密码解密失败: {type(e).__name__}: {e}")
|
||||
return LDAPTestResponse(
|
||||
success=False, message="绑定密码解密失败,请检查配置或重新设置密码"
|
||||
).model_dump()
|
||||
|
||||
# 必填字段检查
|
||||
required_fields = ["server_url", "bind_dn", "base_dn", "bind_password"]
|
||||
missing = [f for f in required_fields if not config_data.get(f)]
|
||||
if missing:
|
||||
return LDAPTestResponse(
|
||||
success=False, message=f"缺少必要字段: {', '.join(missing)}"
|
||||
).model_dump()
|
||||
|
||||
success, message = LDAPService.test_connection_with_config(config_data)
|
||||
return LDAPTestResponse(success=success, message=message).model_dump()
|
||||
10
_deprecated_py_src/api/admin/management_tokens/__init__.py
Normal file
10
_deprecated_py_src/api/admin/management_tokens/__init__.py
Normal file
@@ -0,0 +1,10 @@
|
||||
"""Management Token 管理员路由模块"""
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from .routes import router as management_tokens_router
|
||||
|
||||
router = APIRouter()
|
||||
router.include_router(management_tokens_router)
|
||||
|
||||
__all__ = ["router"]
|
||||
295
_deprecated_py_src/api/admin/management_tokens/routes.py
Normal file
295
_deprecated_py_src/api/admin/management_tokens/routes.py
Normal file
@@ -0,0 +1,295 @@
|
||||
"""管理员 Management Token 管理端点"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from src.api.base.admin_adapter import AdminApiAdapter
|
||||
from src.api.base.context import ApiRequestContext
|
||||
from src.api.base.pipeline import get_pipeline
|
||||
from src.core.exceptions import NotFoundException
|
||||
from src.database import get_db
|
||||
from src.models.database import AuditEventType, ManagementToken, User
|
||||
from src.services.management_token import ManagementTokenService, token_to_dict
|
||||
|
||||
router = APIRouter(prefix="/api/admin/management-tokens", tags=["Admin - Management Tokens"])
|
||||
pipeline = get_pipeline()
|
||||
|
||||
|
||||
# ============== 安全基类 ==============
|
||||
|
||||
|
||||
class AdminManagementTokenApiAdapter(AdminApiAdapter):
|
||||
"""管理员 Management Token 管理 API 的基类
|
||||
|
||||
安全限制:禁止使用 Management Token 调用这些接口。
|
||||
"""
|
||||
|
||||
def authorize(self, context: ApiRequestContext) -> None:
|
||||
# 先调用父类的认证和权限检查
|
||||
super().authorize(context)
|
||||
|
||||
# 禁止使用 Management Token 调用 management-tokens 相关接口
|
||||
if context.management_token is not None:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="不允许使用 Management Token 管理其他 Token,请使用 Web 界面或 JWT 认证",
|
||||
)
|
||||
|
||||
|
||||
# ============== 路由 ==============
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_all_management_tokens(
|
||||
request: Request,
|
||||
user_id: str | None = Query(None, description="筛选用户 ID"),
|
||||
is_active: bool | None = Query(None, description="筛选激活状态"),
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(50, ge=1, le=100),
|
||||
db: Session = Depends(get_db),
|
||||
) -> Any:
|
||||
"""列出所有 Management Tokens(管理员)
|
||||
|
||||
管理员查看所有用户的 Management Tokens,支持筛选和分页。
|
||||
|
||||
**查询参数**
|
||||
- user_id (Optional[str]): 筛选指定用户 ID 的 tokens
|
||||
- is_active (Optional[bool]): 筛选激活状态(true/false)
|
||||
- skip (int): 分页偏移量,默认 0
|
||||
- limit (int): 每页数量,范围 1-100,默认 50
|
||||
|
||||
**返回字段**
|
||||
- items (List[dict]): Token 列表
|
||||
- id (str): Token ID
|
||||
- user_id (str): 所属用户 ID
|
||||
- user (dict): 用户信息(包含 id, username, email 等)
|
||||
- name (str): Token 名称
|
||||
- description (Optional[str]): 描述
|
||||
- token_hash (str): Token 哈希值(不返回明文)
|
||||
- is_active (bool): 是否激活
|
||||
- allowed_ips (Optional[List[str]]): IP 白名单
|
||||
- expires_at (Optional[str]): 过期时间(ISO 8601 格式)
|
||||
- last_used_at (Optional[str]): 最后使用时间
|
||||
- created_at (str): 创建时间
|
||||
- updated_at (str): 更新时间
|
||||
- total (int): 总数量
|
||||
- skip (int): 当前偏移量
|
||||
- limit (int): 当前每页数量
|
||||
"""
|
||||
adapter = AdminListManagementTokensAdapter(
|
||||
user_id=user_id, is_active=is_active, skip=skip, limit=limit
|
||||
)
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
|
||||
@router.get("/{token_id}")
|
||||
async def get_management_token(
|
||||
token_id: str,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
) -> Any:
|
||||
"""获取 Management Token 详情(管理员)
|
||||
|
||||
管理员查看任意 Management Token 的详细信息。
|
||||
|
||||
**路径参数**
|
||||
- token_id (str): Token ID
|
||||
|
||||
**返回字段**
|
||||
- id (str): Token ID
|
||||
- user_id (str): 所属用户 ID
|
||||
- user (dict): 用户信息(包含 id, username, email 等)
|
||||
- name (str): Token 名称
|
||||
- description (Optional[str]): 描述
|
||||
- token_hash (str): Token 哈希值(不返回明文)
|
||||
- is_active (bool): 是否激活
|
||||
- allowed_ips (Optional[List[str]]): IP 白名单
|
||||
- expires_at (Optional[str]): 过期时间(ISO 8601 格式)
|
||||
- last_used_at (Optional[str]): 最后使用时间
|
||||
- created_at (str): 创建时间
|
||||
- updated_at (str): 更新时间
|
||||
"""
|
||||
adapter = AdminGetManagementTokenAdapter(token_id=token_id)
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
|
||||
@router.delete("/{token_id}")
|
||||
async def delete_management_token(
|
||||
token_id: str, request: Request, db: Session = Depends(get_db)
|
||||
) -> Any:
|
||||
"""删除任意 Management Token(管理员)
|
||||
|
||||
管理员可以删除任意用户的 Management Token。
|
||||
|
||||
**路径参数**
|
||||
- token_id (str): 要删除的 Token ID
|
||||
|
||||
**返回字段**
|
||||
- message (str): 操作结果消息
|
||||
"""
|
||||
adapter = AdminDeleteManagementTokenAdapter(token_id=token_id)
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
|
||||
@router.patch("/{token_id}/status")
|
||||
async def toggle_management_token(
|
||||
token_id: str, request: Request, db: Session = Depends(get_db)
|
||||
) -> Any:
|
||||
"""切换任意 Management Token 状态(管理员)
|
||||
|
||||
管理员可以启用/禁用任意用户的 Management Token。
|
||||
|
||||
**路径参数**
|
||||
- token_id (str): Token ID
|
||||
|
||||
**返回字段**
|
||||
- message (str): 操作结果消息("Token 已启用" 或 "Token 已禁用")
|
||||
- data (dict): 更新后的 Token 信息
|
||||
- id (str): Token ID
|
||||
- user_id (str): 所属用户 ID
|
||||
- user (dict): 用户信息
|
||||
- name (str): Token 名称
|
||||
- description (Optional[str]): 描述
|
||||
- token_hash (str): Token 哈希值
|
||||
- is_active (bool): 是否激活(已切换后的状态)
|
||||
- allowed_ips (Optional[List[str]]): IP 白名单
|
||||
- expires_at (Optional[str]): 过期时间
|
||||
- last_used_at (Optional[str]): 最后使用时间
|
||||
- created_at (str): 创建时间
|
||||
- updated_at (str): 更新时间
|
||||
"""
|
||||
adapter = AdminToggleManagementTokenAdapter(token_id=token_id)
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
|
||||
# ============== 适配器 ==============
|
||||
|
||||
|
||||
@dataclass
|
||||
class AdminListManagementTokensAdapter(AdminManagementTokenApiAdapter):
|
||||
"""列出所有 Management Tokens"""
|
||||
|
||||
name: str = "admin_list_management_tokens"
|
||||
user_id: str | None = None
|
||||
is_active: bool | None = None
|
||||
skip: int = 0
|
||||
limit: int = 50
|
||||
|
||||
async def handle(self, context: ApiRequestContext) -> Any:
|
||||
# 构建查询
|
||||
query = context.db.query(ManagementToken)
|
||||
|
||||
if self.user_id:
|
||||
query = query.filter(ManagementToken.user_id == self.user_id)
|
||||
if self.is_active is not None:
|
||||
query = query.filter(ManagementToken.is_active == self.is_active)
|
||||
|
||||
total = int(query.with_entities(func.count(ManagementToken.id)).scalar() or 0)
|
||||
tokens = (
|
||||
query.order_by(ManagementToken.created_at.desc())
|
||||
.offset(self.skip)
|
||||
.limit(self.limit)
|
||||
.all()
|
||||
)
|
||||
|
||||
# 预加载用户信息
|
||||
user_ids = list({t.user_id for t in tokens})
|
||||
users = {u.id: u for u in context.db.query(User).filter(User.id.in_(user_ids)).all()}
|
||||
for token in tokens:
|
||||
token.user = users.get(token.user_id)
|
||||
|
||||
return JSONResponse(
|
||||
content={
|
||||
"items": [token_to_dict(t, include_user=True) for t in tokens],
|
||||
"total": total,
|
||||
"skip": self.skip,
|
||||
"limit": self.limit,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class AdminGetManagementTokenAdapter(AdminManagementTokenApiAdapter):
|
||||
"""获取 Management Token 详情"""
|
||||
|
||||
name: str = "admin_get_management_token"
|
||||
token_id: str = ""
|
||||
|
||||
async def handle(self, context: ApiRequestContext) -> Any:
|
||||
token = ManagementTokenService.get_token_by_id(db=context.db, token_id=self.token_id)
|
||||
|
||||
if not token:
|
||||
raise NotFoundException("Management Token 不存在")
|
||||
|
||||
# 加载用户信息
|
||||
token.user = context.db.query(User).filter(User.id == token.user_id).first()
|
||||
|
||||
return JSONResponse(content=token_to_dict(token, include_user=True))
|
||||
|
||||
|
||||
@dataclass
|
||||
class AdminDeleteManagementTokenAdapter(AdminManagementTokenApiAdapter):
|
||||
"""删除 Management Token"""
|
||||
|
||||
name: str = "admin_delete_management_token"
|
||||
token_id: str = ""
|
||||
audit_success_event = AuditEventType.MANAGEMENT_TOKEN_DELETED
|
||||
|
||||
async def handle(self, context: ApiRequestContext) -> Any:
|
||||
# 先获取 token 信息用于审计
|
||||
token = ManagementTokenService.get_token_by_id(db=context.db, token_id=self.token_id)
|
||||
|
||||
if not token:
|
||||
raise NotFoundException("Management Token 不存在")
|
||||
|
||||
context.add_audit_metadata(
|
||||
token_id=token.id,
|
||||
token_name=token.name,
|
||||
owner_user_id=token.user_id,
|
||||
)
|
||||
|
||||
success = ManagementTokenService.delete_token(db=context.db, token_id=self.token_id)
|
||||
|
||||
if not success:
|
||||
raise NotFoundException("Management Token 不存在")
|
||||
|
||||
return JSONResponse(content={"message": "删除成功"})
|
||||
|
||||
|
||||
@dataclass
|
||||
class AdminToggleManagementTokenAdapter(AdminManagementTokenApiAdapter):
|
||||
"""切换 Management Token 状态"""
|
||||
|
||||
name: str = "admin_toggle_management_token"
|
||||
token_id: str = ""
|
||||
audit_success_event = AuditEventType.MANAGEMENT_TOKEN_UPDATED
|
||||
|
||||
async def handle(self, context: ApiRequestContext) -> Any:
|
||||
token = ManagementTokenService.toggle_status(db=context.db, token_id=self.token_id)
|
||||
|
||||
if not token:
|
||||
raise NotFoundException("Management Token 不存在")
|
||||
|
||||
# 加载用户信息
|
||||
token.user = context.db.query(User).filter(User.id == token.user_id).first()
|
||||
|
||||
context.add_audit_metadata(
|
||||
token_id=token.id,
|
||||
token_name=token.name,
|
||||
owner_user_id=token.user_id,
|
||||
is_active=token.is_active,
|
||||
)
|
||||
|
||||
return JSONResponse(
|
||||
content={
|
||||
"message": f"Token 已{'启用' if token.is_active else '禁用'}",
|
||||
"data": token_to_dict(token, include_user=True),
|
||||
}
|
||||
)
|
||||
18
_deprecated_py_src/api/admin/models/__init__.py
Normal file
18
_deprecated_py_src/api/admin/models/__init__.py
Normal file
@@ -0,0 +1,18 @@
|
||||
"""
|
||||
模型管理相关 Admin API
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from .catalog import router as catalog_router
|
||||
from .external import router as external_router
|
||||
from .global_models import router as global_models_router
|
||||
from .routing import router as routing_router
|
||||
|
||||
router = APIRouter(prefix="/api/admin/models", tags=["Admin - Models"])
|
||||
|
||||
# 挂载子路由
|
||||
router.include_router(catalog_router)
|
||||
router.include_router(global_models_router)
|
||||
router.include_router(external_router)
|
||||
router.include_router(routing_router)
|
||||
172
_deprecated_py_src/api/admin/models/catalog.py
Normal file
172
_deprecated_py_src/api/admin/models/catalog.py
Normal file
@@ -0,0 +1,172 @@
|
||||
"""
|
||||
统一模型目录 Admin API
|
||||
|
||||
基于 GlobalModel 的聚合视图
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from sqlalchemy.orm import Session, joinedload
|
||||
|
||||
from src.api.base.admin_adapter import AdminApiAdapter
|
||||
from src.api.base.context import ApiRequestContext
|
||||
from src.api.base.pipeline import get_pipeline
|
||||
from src.database import get_db
|
||||
from src.models.database import GlobalModel, Model
|
||||
from src.models.pydantic_models import (
|
||||
ModelCapabilities,
|
||||
ModelCatalogItem,
|
||||
ModelCatalogProviderDetail,
|
||||
ModelCatalogResponse,
|
||||
ModelPriceRange,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/catalog", tags=["Admin - Model Catalog"])
|
||||
pipeline = get_pipeline()
|
||||
|
||||
|
||||
@router.get("", response_model=ModelCatalogResponse)
|
||||
async def get_model_catalog(
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
) -> ModelCatalogResponse:
|
||||
"""
|
||||
获取统一模型目录
|
||||
|
||||
基于 GlobalModel 聚合所有活跃模型及其关联提供商的信息,返回完整的模型目录视图。
|
||||
|
||||
**返回字段**:
|
||||
- `models`: 模型列表,每个模型包含:
|
||||
- `global_model_name`: GlobalModel 名称
|
||||
- `display_name`: 显示名称
|
||||
- `description`: 模型描述
|
||||
- `providers`: 提供商列表,包含提供商名称、价格、能力等详细信息
|
||||
- `price_range`: 价格区间(基于 GlobalModel 第一阶梯价格)
|
||||
- `total_providers`: 关联提供商数量
|
||||
- `capabilities`: 模型能力标志(视觉、函数调用、流式输出)
|
||||
- `total`: 模型总数
|
||||
"""
|
||||
adapter = AdminGetModelCatalogAdapter()
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
|
||||
@dataclass
|
||||
class AdminGetModelCatalogAdapter(AdminApiAdapter):
|
||||
"""管理员查询统一模型目录
|
||||
|
||||
架构说明:
|
||||
1. 以 GlobalModel 为中心聚合数据
|
||||
2. Model 表提供关联提供商和价格
|
||||
"""
|
||||
|
||||
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
||||
db: Session = context.db
|
||||
|
||||
# 1. 获取所有活跃的 GlobalModel
|
||||
global_models: list[GlobalModel] = (
|
||||
db.query(GlobalModel).filter(GlobalModel.is_active == True).all()
|
||||
)
|
||||
|
||||
# 2. 获取所有活跃的 Model 实现(包含 global_model 以便计算有效价格)
|
||||
models: list[Model] = (
|
||||
db.query(Model)
|
||||
.options(joinedload(Model.provider), joinedload(Model.global_model))
|
||||
.filter(Model.is_active == True)
|
||||
.all()
|
||||
)
|
||||
|
||||
# 按 GlobalModel ID 组织关联提供商
|
||||
models_by_global_model: dict[str, list[Model]] = {}
|
||||
for model in models:
|
||||
if model.global_model_id:
|
||||
models_by_global_model.setdefault(model.global_model_id, []).append(model)
|
||||
|
||||
# 3. 为每个 GlobalModel 构建 catalog item
|
||||
catalog_items: list[ModelCatalogItem] = []
|
||||
|
||||
for gm in global_models:
|
||||
gm_id = gm.id
|
||||
provider_entries: list[ModelCatalogProviderDetail] = []
|
||||
# 从 config JSON 读取能力标志
|
||||
gm_config = gm.config or {}
|
||||
capability_flags = {
|
||||
"supports_vision": gm_config.get("vision", False),
|
||||
"supports_function_calling": gm_config.get("function_calling", False),
|
||||
"supports_streaming": gm_config.get("streaming", True),
|
||||
}
|
||||
|
||||
# 遍历该 GlobalModel 的所有关联提供商
|
||||
for model in models_by_global_model.get(gm_id, []):
|
||||
provider = model.provider
|
||||
if not provider:
|
||||
continue
|
||||
|
||||
# 使用有效价格(考虑 GlobalModel 默认值)
|
||||
effective_input = model.get_effective_input_price()
|
||||
effective_output = model.get_effective_output_price()
|
||||
effective_tiered = model.get_effective_tiered_pricing()
|
||||
tier_count = len(effective_tiered.get("tiers", [])) if effective_tiered else 1
|
||||
|
||||
# 使用有效能力值
|
||||
capability_flags["supports_vision"] = (
|
||||
capability_flags["supports_vision"] or model.get_effective_supports_vision()
|
||||
)
|
||||
capability_flags["supports_function_calling"] = (
|
||||
capability_flags["supports_function_calling"]
|
||||
or model.get_effective_supports_function_calling()
|
||||
)
|
||||
capability_flags["supports_streaming"] = (
|
||||
capability_flags["supports_streaming"]
|
||||
or model.get_effective_supports_streaming()
|
||||
)
|
||||
|
||||
provider_entries.append(
|
||||
ModelCatalogProviderDetail(
|
||||
provider_id=provider.id,
|
||||
provider_name=provider.name,
|
||||
model_id=model.id,
|
||||
target_model=model.provider_model_name,
|
||||
# 显示有效价格
|
||||
input_price_per_1m=effective_input,
|
||||
output_price_per_1m=effective_output,
|
||||
cache_creation_price_per_1m=model.get_effective_cache_creation_price(),
|
||||
cache_read_price_per_1m=model.get_effective_cache_read_price(),
|
||||
cache_1h_creation_price_per_1m=model.get_effective_1h_cache_creation_price(),
|
||||
price_per_request=model.get_effective_price_per_request(),
|
||||
effective_tiered_pricing=effective_tiered,
|
||||
tier_count=tier_count,
|
||||
supports_vision=model.get_effective_supports_vision(),
|
||||
supports_function_calling=model.get_effective_supports_function_calling(),
|
||||
supports_streaming=model.get_effective_supports_streaming(),
|
||||
is_active=bool(model.is_active),
|
||||
)
|
||||
)
|
||||
|
||||
# 模型目录显示 GlobalModel 的第一个阶梯价格(不是 Provider 聚合价格)
|
||||
tiered = gm.default_tiered_pricing or {}
|
||||
first_tier = tiered.get("tiers", [{}])[0] if tiered.get("tiers") else {}
|
||||
price_range = ModelPriceRange(
|
||||
min_input=first_tier.get("input_price_per_1m", 0),
|
||||
max_input=first_tier.get("input_price_per_1m", 0),
|
||||
min_output=first_tier.get("output_price_per_1m", 0),
|
||||
max_output=first_tier.get("output_price_per_1m", 0),
|
||||
)
|
||||
|
||||
catalog_items.append(
|
||||
ModelCatalogItem(
|
||||
global_model_name=gm.name,
|
||||
display_name=gm.display_name,
|
||||
description=gm_config.get("description"),
|
||||
providers=provider_entries,
|
||||
price_range=price_range,
|
||||
total_providers=len(provider_entries),
|
||||
capabilities=ModelCapabilities(**capability_flags),
|
||||
)
|
||||
)
|
||||
|
||||
return ModelCatalogResponse(
|
||||
models=catalog_items,
|
||||
total=len(catalog_items),
|
||||
)
|
||||
179
_deprecated_py_src/api/admin/models/external.py
Normal file
179
_deprecated_py_src/api/admin/models/external.py
Normal file
@@ -0,0 +1,179 @@
|
||||
"""
|
||||
models.dev 外部模型数据代理
|
||||
"""
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from src.api.base.admin_adapter import AdminApiAdapter
|
||||
from src.api.base.context import ApiRequestContext
|
||||
from src.api.base.pipeline import get_pipeline
|
||||
from src.clients import get_redis_client
|
||||
from src.core.logger import logger
|
||||
from src.database import get_db
|
||||
from src.models.database import User
|
||||
from src.utils.auth_utils import require_admin
|
||||
|
||||
router = APIRouter()
|
||||
pipeline = get_pipeline()
|
||||
|
||||
CACHE_KEY = "aether:external:models_dev"
|
||||
CACHE_TTL = 15 * 60 # 15 分钟
|
||||
|
||||
# 标记官方/一手提供商,前端可据此过滤第三方转售商
|
||||
OFFICIAL_PROVIDERS = {
|
||||
"anthropic", # Claude 官方
|
||||
"openai", # OpenAI 官方
|
||||
"google", # Gemini 官方
|
||||
"google-vertex", # Google Vertex AI
|
||||
"azure", # Azure OpenAI
|
||||
"amazon-bedrock", # AWS Bedrock
|
||||
"xai", # Grok 官方
|
||||
"meta", # Llama 官方
|
||||
"deepseek", # DeepSeek 官方
|
||||
"mistral", # Mistral 官方
|
||||
"cohere", # Cohere 官方
|
||||
"zhipuai", # 智谱 AI 官方
|
||||
"alibaba", # 阿里云(通义千问)
|
||||
"minimax", # MiniMax 官方
|
||||
"moonshot", # 月之暗面(Kimi)
|
||||
"baichuan", # 百川智能
|
||||
"ai21", # AI21 Labs
|
||||
}
|
||||
|
||||
|
||||
async def _get_cached_data() -> dict[str, Any] | None:
|
||||
"""从 Redis 获取缓存数据"""
|
||||
redis = await get_redis_client()
|
||||
if redis is None:
|
||||
return None
|
||||
try:
|
||||
cached = await redis.get(CACHE_KEY)
|
||||
if cached:
|
||||
result: dict[str, Any] = json.loads(cached)
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.warning(f"读取 models.dev 缓存失败: {e}")
|
||||
return None
|
||||
|
||||
|
||||
async def _set_cached_data(data: dict) -> None:
|
||||
"""将数据写入 Redis 缓存"""
|
||||
redis = await get_redis_client()
|
||||
if redis is None:
|
||||
return
|
||||
try:
|
||||
await redis.setex(CACHE_KEY, CACHE_TTL, json.dumps(data, ensure_ascii=False))
|
||||
except Exception as e:
|
||||
logger.warning(f"写入 models.dev 缓存失败: {e}")
|
||||
|
||||
|
||||
def _mark_official_providers(data: dict[str, Any]) -> dict[str, Any]:
|
||||
"""为每个提供商标记是否为官方"""
|
||||
result = {}
|
||||
for provider_id, provider_data in data.items():
|
||||
result[provider_id] = {
|
||||
**provider_data,
|
||||
"official": provider_id in OFFICIAL_PROVIDERS,
|
||||
}
|
||||
return result
|
||||
|
||||
|
||||
async def _get_external_models_response() -> JSONResponse:
|
||||
"""
|
||||
获取外部模型数据
|
||||
|
||||
从 models.dev 获取第三方模型数据,用于导入新模型或参考定价信息。
|
||||
该接口作为代理请求解决跨域问题,并提供缓存优化。
|
||||
|
||||
**功能特性**:
|
||||
- 代理 models.dev API,解决前端跨域问题
|
||||
- 使用 Redis 缓存 15 分钟,多 worker 共享缓存
|
||||
- 自动标记官方提供商(official 字段),前端可据此过滤第三方转售商
|
||||
|
||||
**返回字段**:
|
||||
- 键为提供商 ID(如 "anthropic"、"openai")
|
||||
- 值为提供商详细信息,包含:
|
||||
- `official`: 是否为官方提供商(true/false)
|
||||
- 其他 models.dev 提供的原始字段(模型列表、定价等)
|
||||
"""
|
||||
# 检查缓存
|
||||
cached = await _get_cached_data()
|
||||
if cached is not None:
|
||||
# 兼容旧缓存:如果没有 official 字段则补全并回写
|
||||
try:
|
||||
needs_mark = False
|
||||
for provider_data in cached.values():
|
||||
if not isinstance(provider_data, dict) or "official" not in provider_data:
|
||||
needs_mark = True
|
||||
break
|
||||
if needs_mark:
|
||||
marked_cached = _mark_official_providers(cached)
|
||||
await _set_cached_data(marked_cached)
|
||||
return JSONResponse(content=marked_cached)
|
||||
except Exception as e:
|
||||
logger.warning(f"处理 models.dev 缓存数据失败,将直接返回原缓存: {e}")
|
||||
return JSONResponse(content=cached)
|
||||
|
||||
raise HTTPException(status_code=503, detail="External models catalog requires Rust admin backend")
|
||||
|
||||
|
||||
async def _clear_external_models_cache_response() -> dict[str, Any]:
|
||||
"""
|
||||
清除外部模型数据缓存
|
||||
|
||||
手动清除 models.dev 的 Redis 缓存,强制下次请求重新获取最新数据。
|
||||
通常用于需要立即更新外部模型数据的场景。
|
||||
|
||||
**返回字段**:
|
||||
- `cleared`: 是否成功清除缓存(true/false)
|
||||
- `message`: 提示信息(仅在 Redis 未启用时返回)
|
||||
"""
|
||||
redis = await get_redis_client()
|
||||
if redis is None:
|
||||
return {"cleared": False, "message": "Redis 未启用"}
|
||||
try:
|
||||
await redis.delete(CACHE_KEY)
|
||||
return {"cleared": True}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"清除缓存失败: {str(e)}")
|
||||
|
||||
|
||||
class ExternalModelsAdminAdapter(AdminApiAdapter):
|
||||
"""models.dev 外部模型管理基类。"""
|
||||
|
||||
|
||||
class AdminGetExternalModelsAdapter(ExternalModelsAdminAdapter):
|
||||
async def handle(self, context: ApiRequestContext) -> JSONResponse: # type: ignore[override]
|
||||
del context
|
||||
return await _get_external_models_response()
|
||||
|
||||
|
||||
class AdminClearExternalModelsCacheAdapter(ExternalModelsAdminAdapter):
|
||||
async def handle(self, context: ApiRequestContext) -> dict[str, Any]: # type: ignore[override]
|
||||
del context
|
||||
return await _clear_external_models_cache_response()
|
||||
|
||||
|
||||
@router.get("/external")
|
||||
async def get_external_models(
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
_: User = Depends(require_admin),
|
||||
) -> JSONResponse:
|
||||
adapter = AdminGetExternalModelsAdapter()
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
|
||||
@router.delete("/external/cache")
|
||||
async def clear_external_models_cache(
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
_: User = Depends(require_admin),
|
||||
) -> dict[str, Any]:
|
||||
adapter = AdminClearExternalModelsCacheAdapter()
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
671
_deprecated_py_src/api/admin/models/global_models.py
Normal file
671
_deprecated_py_src/api/admin/models/global_models.py
Normal file
@@ -0,0 +1,671 @@
|
||||
"""
|
||||
GlobalModel Admin API
|
||||
|
||||
提供 GlobalModel 的 CRUD 操作接口
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, Query, Request, Response
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from src.api.base.admin_adapter import AdminApiAdapter
|
||||
from src.api.base.context import ApiRequestContext
|
||||
from src.api.base.models_service import invalidate_models_list_cache
|
||||
from src.api.base.pipeline import get_pipeline
|
||||
from src.core.logger import logger
|
||||
from src.database import get_db
|
||||
from src.models.pydantic_models import (
|
||||
BatchAssignToProvidersRequest,
|
||||
BatchAssignToProvidersResponse,
|
||||
GlobalModelCreate,
|
||||
GlobalModelListResponse,
|
||||
GlobalModelProvidersResponse,
|
||||
GlobalModelResponse,
|
||||
GlobalModelUpdate,
|
||||
GlobalModelWithStats,
|
||||
ModelCatalogProviderDetail,
|
||||
)
|
||||
from src.services.model.global_model import GlobalModelService
|
||||
|
||||
router = APIRouter(prefix="/global", tags=["Admin - Global Models"])
|
||||
pipeline = get_pipeline()
|
||||
|
||||
|
||||
@router.get("", response_model=GlobalModelListResponse)
|
||||
async def list_global_models(
|
||||
request: Request,
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(100, ge=1, le=1000),
|
||||
is_active: bool | None = Query(None),
|
||||
search: str | None = Query(None),
|
||||
db: Session = Depends(get_db),
|
||||
) -> GlobalModelListResponse:
|
||||
"""
|
||||
获取 GlobalModel 列表
|
||||
|
||||
查询系统中的全局模型列表,支持分页、过滤和搜索功能。
|
||||
|
||||
**查询参数**:
|
||||
- `skip`: 跳过记录数,用于分页(默认 0)
|
||||
- `limit`: 返回记录数,用于分页(默认 100,最大 1000)
|
||||
- `is_active`: 过滤活跃状态(true/false/null,null 表示不过滤)
|
||||
- `search`: 搜索关键词,支持按名称或显示名称模糊搜索
|
||||
|
||||
**返回字段**:
|
||||
- `models`: GlobalModel 列表,每个包含:
|
||||
- `id`: GlobalModel ID
|
||||
- `name`: 模型名称(唯一)
|
||||
- `display_name`: 显示名称
|
||||
- `is_active`: 是否活跃
|
||||
- `provider_count`: 关联提供商数量
|
||||
- 定价和能力配置等其他字段
|
||||
- `total`: 返回的模型总数
|
||||
"""
|
||||
adapter = AdminListGlobalModelsAdapter(
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
is_active=is_active,
|
||||
search=search,
|
||||
)
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
|
||||
@router.get("/{global_model_id}", response_model=GlobalModelWithStats)
|
||||
async def get_global_model(
|
||||
request: Request,
|
||||
global_model_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
) -> GlobalModelWithStats:
|
||||
"""
|
||||
获取单个 GlobalModel 详情
|
||||
|
||||
查询指定 GlobalModel 的详细信息,包含关联的提供商和价格统计数据。
|
||||
|
||||
**路径参数**:
|
||||
- `global_model_id`: GlobalModel ID
|
||||
|
||||
**返回字段**:
|
||||
- 基础字段:`id`, `name`, `display_name`, `is_active` 等
|
||||
- 统计字段:
|
||||
- `total_models`: 关联的 Model 实现数量
|
||||
- `total_providers`: 关联的提供商数量
|
||||
- `price_range`: 价格区间统计(最低/最高输入输出价格)
|
||||
"""
|
||||
adapter = AdminGetGlobalModelAdapter(global_model_id=global_model_id)
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
|
||||
@router.post("", response_model=GlobalModelResponse, status_code=201)
|
||||
async def create_global_model(
|
||||
request: Request,
|
||||
payload: GlobalModelCreate,
|
||||
db: Session = Depends(get_db),
|
||||
) -> GlobalModelResponse:
|
||||
"""
|
||||
创建 GlobalModel
|
||||
|
||||
创建一个新的全局模型定义,作为多个提供商实现的统一抽象。
|
||||
|
||||
**请求体字段**:
|
||||
- `name`: 模型名称(唯一标识,如 "claude-3-5-sonnet-20241022")
|
||||
- `display_name`: 显示名称(如 "Claude 3.5 Sonnet")
|
||||
- `is_active`: 是否活跃(默认 true)
|
||||
- `default_price_per_request`: 默认按次计费价格(可选)
|
||||
- `default_tiered_pricing`: 默认阶梯定价配置(包含多个价格阶梯)
|
||||
- `supported_capabilities`: 支持的能力标志(vision、function_calling、streaming)
|
||||
- `config`: 额外配置(JSON 格式,如 description、context_window 等)
|
||||
|
||||
**返回字段**:
|
||||
- `id`: 创建的 GlobalModel ID
|
||||
- 其他请求体中的所有字段
|
||||
"""
|
||||
adapter = AdminCreateGlobalModelAdapter(payload=payload)
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
|
||||
@router.patch("/{global_model_id}", response_model=GlobalModelResponse)
|
||||
async def update_global_model(
|
||||
request: Request,
|
||||
global_model_id: str,
|
||||
payload: GlobalModelUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
) -> GlobalModelResponse:
|
||||
"""
|
||||
更新 GlobalModel
|
||||
|
||||
更新指定 GlobalModel 的配置信息,支持部分字段更新。
|
||||
更新后会自动失效相关缓存。
|
||||
|
||||
**路径参数**:
|
||||
- `global_model_id`: GlobalModel ID
|
||||
|
||||
**请求体字段**(均为可选):
|
||||
- `display_name`: 显示名称
|
||||
- `is_active`: 是否活跃
|
||||
- `default_price_per_request`: 默认按次计费价格
|
||||
- `default_tiered_pricing`: 默认阶梯定价配置
|
||||
- `supported_capabilities`: 支持的能力标志
|
||||
- `config`: 额外配置
|
||||
|
||||
**返回字段**:
|
||||
- 更新后的完整 GlobalModel 信息
|
||||
"""
|
||||
adapter = AdminUpdateGlobalModelAdapter(global_model_id=global_model_id, payload=payload)
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
|
||||
@router.delete("/{global_model_id}", status_code=204, response_class=Response)
|
||||
async def delete_global_model(
|
||||
request: Request,
|
||||
global_model_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
) -> Response:
|
||||
"""
|
||||
删除 GlobalModel
|
||||
|
||||
删除指定的 GlobalModel,会级联删除所有关联的 Provider 模型实现。
|
||||
删除后会自动失效相关缓存。
|
||||
|
||||
**路径参数**:
|
||||
- `global_model_id`: GlobalModel ID
|
||||
|
||||
**返回**:
|
||||
- 成功删除返回 204 状态码,无响应体
|
||||
"""
|
||||
adapter = AdminDeleteGlobalModelAdapter(global_model_id=global_model_id)
|
||||
await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
return Response(status_code=204)
|
||||
|
||||
|
||||
@router.post("/batch-delete")
|
||||
async def batch_delete_global_models(
|
||||
request: Request,
|
||||
ids: list[str] = Body(..., embed=True, max_length=100),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
"""
|
||||
批量删除 GlobalModel
|
||||
|
||||
顺序删除多个 GlobalModel(每个独立提交),避免并行删除导致的锁竞争。
|
||||
"""
|
||||
adapter = AdminBatchDeleteGlobalModelsAdapter(ids=ids)
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{global_model_id}/assign-to-providers", response_model=BatchAssignToProvidersResponse
|
||||
)
|
||||
async def batch_assign_to_providers(
|
||||
request: Request,
|
||||
global_model_id: str,
|
||||
payload: BatchAssignToProvidersRequest,
|
||||
db: Session = Depends(get_db),
|
||||
) -> BatchAssignToProvidersResponse:
|
||||
"""
|
||||
批量为提供商添加模型实现
|
||||
|
||||
为指定的 GlobalModel 批量创建多个 Provider 的模型实现(Model 记录)。
|
||||
用于快速将一个统一模型分配给多个提供商。
|
||||
|
||||
**路径参数**:
|
||||
- `global_model_id`: GlobalModel ID
|
||||
|
||||
**请求体字段**:
|
||||
- `provider_ids`: 提供商 ID 列表
|
||||
- `create_models`: Model 创建配置列表,每个包含:
|
||||
- `provider_id`: 提供商 ID
|
||||
- `provider_model_name`: 提供商侧的模型名称(如 "claude-3-5-sonnet-20241022")
|
||||
- 其他可选字段(价格覆盖、能力覆盖等)
|
||||
|
||||
**返回字段**:
|
||||
- `success`: 成功创建的 Model 列表
|
||||
- `errors`: 失败的提供商及错误信息列表
|
||||
- `total_requested`: 请求处理的总数
|
||||
- `total_success`: 成功创建的数量
|
||||
- `total_errors`: 失败的数量
|
||||
"""
|
||||
adapter = AdminBatchAssignToProvidersAdapter(global_model_id=global_model_id, payload=payload)
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
|
||||
@router.get("/{global_model_id}/providers", response_model=GlobalModelProvidersResponse)
|
||||
async def get_global_model_providers(
|
||||
request: Request,
|
||||
global_model_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
) -> GlobalModelProvidersResponse:
|
||||
"""
|
||||
获取 GlobalModel 的关联提供商
|
||||
|
||||
查询指定 GlobalModel 的所有关联提供商及其模型实现详情,包括非活跃的提供商。
|
||||
用于查看某个统一模型在各个提供商上的具体配置。
|
||||
|
||||
**路径参数**:
|
||||
- `global_model_id`: GlobalModel ID
|
||||
|
||||
**返回字段**:
|
||||
- `providers`: 提供商列表,每个包含:
|
||||
- `provider_id`: 提供商 ID
|
||||
- `provider_name`: 提供商名称
|
||||
- `provider_display_name`: 提供商显示名称
|
||||
- `model_id`: Model 实现 ID
|
||||
- `target_model`: 提供商侧的模型名称
|
||||
- 价格信息(input_price_per_1m、output_price_per_1m 等)
|
||||
- 能力标志(supports_vision、supports_function_calling、supports_streaming)
|
||||
- `is_active`: 是否活跃
|
||||
- `total`: 关联提供商总数
|
||||
"""
|
||||
adapter = AdminGetGlobalModelProvidersAdapter(global_model_id=global_model_id)
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
|
||||
# ========== Adapters ==========
|
||||
|
||||
|
||||
@dataclass
|
||||
class AdminListGlobalModelsAdapter(AdminApiAdapter):
|
||||
"""列出 GlobalModel"""
|
||||
|
||||
skip: int
|
||||
limit: int
|
||||
is_active: bool | None
|
||||
search: str | None
|
||||
|
||||
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
||||
from sqlalchemy import and_, case, func, or_
|
||||
|
||||
from src.models.database import GlobalModel, Model, Provider
|
||||
|
||||
query = context.db.query(GlobalModel)
|
||||
if self.is_active is not None:
|
||||
query = query.filter(GlobalModel.is_active == self.is_active)
|
||||
if self.search:
|
||||
search_pattern = f"%{self.search}%"
|
||||
query = query.filter(
|
||||
or_(
|
||||
GlobalModel.name.ilike(search_pattern),
|
||||
GlobalModel.display_name.ilike(search_pattern),
|
||||
)
|
||||
)
|
||||
|
||||
total = int(query.with_entities(func.count(GlobalModel.id)).scalar() or 0)
|
||||
models = query.order_by(GlobalModel.name).offset(self.skip).limit(self.limit).all()
|
||||
|
||||
# 一次性查询所有 GlobalModel 的 provider_count(优化 N+1 问题)
|
||||
# 用条件聚合同时获取总数和活跃数,减少一次 DB 往返
|
||||
model_ids = [gm.id for gm in models]
|
||||
provider_counts = {}
|
||||
active_provider_counts = {}
|
||||
if model_ids:
|
||||
count_results = (
|
||||
context.db.query(
|
||||
Model.global_model_id,
|
||||
func.count(func.distinct(Model.provider_id)),
|
||||
func.count(
|
||||
func.distinct(
|
||||
case(
|
||||
(
|
||||
and_(
|
||||
Model.is_active.is_(True),
|
||||
Provider.is_active.is_(True),
|
||||
),
|
||||
Model.provider_id,
|
||||
),
|
||||
else_=None,
|
||||
)
|
||||
)
|
||||
),
|
||||
)
|
||||
.join(Provider, Model.provider_id == Provider.id)
|
||||
.filter(Model.global_model_id.in_(model_ids))
|
||||
.group_by(Model.global_model_id)
|
||||
.all()
|
||||
)
|
||||
provider_counts = {gm_id: total for gm_id, total, _ in count_results}
|
||||
active_provider_counts = {gm_id: active for gm_id, _, active in count_results}
|
||||
|
||||
# 构建响应
|
||||
model_responses = []
|
||||
for gm in models:
|
||||
response = GlobalModelResponse.model_validate(gm)
|
||||
response.provider_count = provider_counts.get(gm.id, 0)
|
||||
response.active_provider_count = active_provider_counts.get(gm.id, 0)
|
||||
model_responses.append(response)
|
||||
|
||||
return GlobalModelListResponse(
|
||||
models=model_responses,
|
||||
total=total,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class AdminGetGlobalModelAdapter(AdminApiAdapter):
|
||||
"""获取单个 GlobalModel"""
|
||||
|
||||
global_model_id: str
|
||||
|
||||
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
||||
from sqlalchemy import and_, case, func
|
||||
|
||||
from src.models.database import Model, Provider
|
||||
|
||||
global_model = GlobalModelService.get_global_model(context.db, self.global_model_id)
|
||||
stats = GlobalModelService.get_global_model_stats(context.db, self.global_model_id)
|
||||
|
||||
# total_providers 已由 stats 提供,这里只查询活跃 provider 数量
|
||||
active_count = (
|
||||
context.db.query(
|
||||
func.count(
|
||||
func.distinct(
|
||||
case(
|
||||
(
|
||||
and_(
|
||||
Model.is_active.is_(True),
|
||||
Provider.is_active.is_(True),
|
||||
),
|
||||
Model.provider_id,
|
||||
),
|
||||
else_=None,
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
.join(Provider, Model.provider_id == Provider.id)
|
||||
.filter(Model.global_model_id == global_model.id)
|
||||
.scalar()
|
||||
or 0
|
||||
)
|
||||
|
||||
response = GlobalModelResponse.model_validate(global_model)
|
||||
response.provider_count = stats["total_providers"]
|
||||
response.active_provider_count = int(active_count)
|
||||
|
||||
return GlobalModelWithStats(
|
||||
**response.model_dump(),
|
||||
total_models=stats["total_models"],
|
||||
total_providers=stats["total_providers"],
|
||||
price_range=stats["price_range"],
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class AdminCreateGlobalModelAdapter(AdminApiAdapter):
|
||||
"""创建 GlobalModel"""
|
||||
|
||||
payload: GlobalModelCreate
|
||||
|
||||
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
||||
from src.core.exceptions import InvalidRequestException
|
||||
from src.core.model_permissions import validate_and_extract_model_mappings
|
||||
|
||||
# 验证 model_mappings(如果有)
|
||||
is_valid, error, _ = validate_and_extract_model_mappings(self.payload.config)
|
||||
if not is_valid:
|
||||
raise InvalidRequestException(f"映射规则验证失败: {error}", "model_mappings")
|
||||
|
||||
# 将 TieredPricingConfig 转换为 dict
|
||||
tiered_pricing_dict = self.payload.default_tiered_pricing.model_dump()
|
||||
|
||||
global_model = GlobalModelService.create_global_model(
|
||||
db=context.db,
|
||||
name=self.payload.name,
|
||||
display_name=self.payload.display_name,
|
||||
is_active=self.payload.is_active,
|
||||
# 按次计费配置
|
||||
default_price_per_request=self.payload.default_price_per_request,
|
||||
# 阶梯计费配置
|
||||
default_tiered_pricing=tiered_pricing_dict,
|
||||
# Key 能力配置
|
||||
supported_capabilities=self.payload.supported_capabilities,
|
||||
# 模型配置(JSON)
|
||||
config=self.payload.config,
|
||||
)
|
||||
|
||||
logger.info(f"GlobalModel 已创建: id={global_model.id} name={global_model.name}")
|
||||
|
||||
# 创建成功后失效缓存(避免 mapping-preview 在 TTL 内读到旧结果)
|
||||
from src.services.cache.invalidation import get_cache_invalidation_service
|
||||
|
||||
cache_service = get_cache_invalidation_service()
|
||||
await cache_service.on_global_model_changed(global_model.name, str(global_model.id))
|
||||
|
||||
return GlobalModelResponse.model_validate(global_model)
|
||||
|
||||
|
||||
@dataclass
|
||||
class AdminUpdateGlobalModelAdapter(AdminApiAdapter):
|
||||
"""更新 GlobalModel"""
|
||||
|
||||
global_model_id: str
|
||||
payload: GlobalModelUpdate
|
||||
|
||||
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
||||
from src.core.exceptions import InvalidRequestException
|
||||
from src.core.model_permissions import validate_and_extract_model_mappings
|
||||
|
||||
# 验证 model_mappings(如果有)
|
||||
is_valid, error, _ = validate_and_extract_model_mappings(self.payload.config)
|
||||
if not is_valid:
|
||||
raise InvalidRequestException(f"映射规则验证失败: {error}", "model_mappings")
|
||||
|
||||
# 使用行级锁获取旧的 GlobalModel 信息,防止并发更新导致的竞态条件
|
||||
# 设置 2 秒锁超时,允许短暂等待而非立即失败,提升并发操作的成功率
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.exc import OperationalError
|
||||
|
||||
from src.models.database import GlobalModel
|
||||
|
||||
try:
|
||||
# 设置会话级别的锁超时(仅影响当前事务)
|
||||
context.db.execute(text("SET LOCAL lock_timeout = '2s'"))
|
||||
old_global_model = (
|
||||
context.db.query(GlobalModel)
|
||||
.filter(GlobalModel.id == self.global_model_id)
|
||||
.with_for_update()
|
||||
.first()
|
||||
)
|
||||
except OperationalError as e:
|
||||
# 锁超时或锁冲突时返回友好的错误提示
|
||||
error_msg = str(e).lower()
|
||||
if "lock" in error_msg or "timeout" in error_msg:
|
||||
raise InvalidRequestException("该模型正在被其他操作更新,请稍后重试")
|
||||
raise
|
||||
old_model_name = old_global_model.name if old_global_model else None
|
||||
|
||||
# 执行更新(此时仍持有行锁)
|
||||
global_model = GlobalModelService.update_global_model(
|
||||
db=context.db,
|
||||
global_model_id=self.global_model_id,
|
||||
update_data=self.payload,
|
||||
)
|
||||
|
||||
logger.info(f"GlobalModel 已更新: id={global_model.id} name={global_model.name}")
|
||||
|
||||
# 更新成功后才失效缓存(避免回滚时缓存已被清除的竞态问题)
|
||||
# 注意:此时事务已提交(由 pipeline 管理),数据已持久化
|
||||
from src.services.cache.invalidation import get_cache_invalidation_service
|
||||
|
||||
cache_service = get_cache_invalidation_service()
|
||||
if old_model_name:
|
||||
await cache_service.on_global_model_changed(old_model_name, self.global_model_id)
|
||||
|
||||
return GlobalModelResponse.model_validate(global_model)
|
||||
|
||||
|
||||
@dataclass
|
||||
class AdminDeleteGlobalModelAdapter(AdminApiAdapter):
|
||||
"""删除 GlobalModel(级联删除所有关联的 Provider 模型实现)"""
|
||||
|
||||
global_model_id: str
|
||||
|
||||
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
||||
# 使用行级锁获取 GlobalModel 信息,防止并发操作导致的竞态条件
|
||||
# 设置 2 秒锁超时,允许短暂等待而非立即失败
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.exc import OperationalError
|
||||
|
||||
from src.core.exceptions import InvalidRequestException
|
||||
from src.models.database import GlobalModel
|
||||
|
||||
try:
|
||||
# 设置会话级别的锁超时(仅影响当前事务)
|
||||
context.db.execute(text("SET LOCAL lock_timeout = '2s'"))
|
||||
global_model = (
|
||||
context.db.query(GlobalModel)
|
||||
.filter(GlobalModel.id == self.global_model_id)
|
||||
.with_for_update()
|
||||
.first()
|
||||
)
|
||||
except OperationalError as e:
|
||||
# 锁超时或锁冲突时返回友好的错误提示
|
||||
error_msg = str(e).lower()
|
||||
if "lock" in error_msg or "timeout" in error_msg:
|
||||
raise InvalidRequestException("该模型正在被其他操作处理,请稍后重试")
|
||||
raise
|
||||
model_name = global_model.name if global_model else None
|
||||
model_id = global_model.id if global_model else self.global_model_id
|
||||
|
||||
# 执行删除(此时仍持有行锁)
|
||||
GlobalModelService.delete_global_model(context.db, self.global_model_id)
|
||||
|
||||
logger.info(f"GlobalModel 已删除: id={self.global_model_id}")
|
||||
|
||||
# 删除成功后才失效缓存(避免回滚时缓存已被清除的竞态问题)
|
||||
from src.services.cache.invalidation import get_cache_invalidation_service
|
||||
|
||||
cache_service = get_cache_invalidation_service()
|
||||
if model_name:
|
||||
await cache_service.on_global_model_changed(model_name, model_id)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
@dataclass
|
||||
class AdminBatchDeleteGlobalModelsAdapter(AdminApiAdapter):
|
||||
"""批量删除多个 GlobalModel(顺序执行,每个删除独立提交)"""
|
||||
|
||||
ids: list[str]
|
||||
|
||||
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
||||
from src.core.exceptions import NotFoundException
|
||||
from src.models.database import GlobalModel
|
||||
|
||||
success_count = 0
|
||||
failed: list[dict] = []
|
||||
deleted_names: list[tuple[str, str]] = [] # (name, id)
|
||||
|
||||
for gm_id in self.ids:
|
||||
try:
|
||||
gm = context.db.query(GlobalModel).filter(GlobalModel.id == gm_id).first()
|
||||
if gm:
|
||||
name = gm.name
|
||||
mid = gm.id
|
||||
GlobalModelService.delete_global_model(context.db, gm_id)
|
||||
deleted_names.append((name, mid))
|
||||
success_count += 1
|
||||
else:
|
||||
failed.append({"id": gm_id, "error": "not found"})
|
||||
except NotFoundException:
|
||||
failed.append({"id": gm_id, "error": "not found"})
|
||||
except Exception as e:
|
||||
context.db.rollback()
|
||||
failed.append({"id": gm_id, "error": str(e)})
|
||||
|
||||
# 批量失效缓存
|
||||
if deleted_names:
|
||||
from src.services.cache.invalidation import get_cache_invalidation_service
|
||||
|
||||
cache_service = get_cache_invalidation_service()
|
||||
for name, mid in deleted_names:
|
||||
await cache_service.on_global_model_changed(name, mid)
|
||||
|
||||
logger.info("批量删除 GlobalModel: success={}, failed={}", success_count, len(failed))
|
||||
|
||||
return {"success_count": success_count, "failed": failed}
|
||||
|
||||
|
||||
@dataclass
|
||||
class AdminBatchAssignToProvidersAdapter(AdminApiAdapter):
|
||||
"""批量为 Provider 添加 GlobalModel 实现"""
|
||||
|
||||
global_model_id: str
|
||||
payload: BatchAssignToProvidersRequest
|
||||
|
||||
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
||||
result = GlobalModelService.batch_assign_to_providers(
|
||||
db=context.db,
|
||||
global_model_id=self.global_model_id,
|
||||
provider_ids=self.payload.provider_ids,
|
||||
create_models=self.payload.create_models,
|
||||
)
|
||||
|
||||
# 如果有成功创建的关联,清除 /v1/models 列表缓存
|
||||
if result["success"]:
|
||||
await invalidate_models_list_cache()
|
||||
|
||||
logger.info(
|
||||
f"批量为 Provider 添加 GlobalModel: global_model_id={self.global_model_id} success={len(result['success'])} errors={len(result['errors'])}"
|
||||
)
|
||||
|
||||
return BatchAssignToProvidersResponse(**result)
|
||||
|
||||
|
||||
@dataclass
|
||||
class AdminGetGlobalModelProvidersAdapter(AdminApiAdapter):
|
||||
"""获取 GlobalModel 的所有关联提供商(包括非活跃的)"""
|
||||
|
||||
global_model_id: str
|
||||
|
||||
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
||||
from sqlalchemy.orm import joinedload
|
||||
|
||||
from src.models.database import Model
|
||||
|
||||
global_model = GlobalModelService.get_global_model(context.db, self.global_model_id)
|
||||
|
||||
# 获取所有关联的 Model(包括非活跃的)
|
||||
models = (
|
||||
context.db.query(Model)
|
||||
.options(joinedload(Model.provider), joinedload(Model.global_model))
|
||||
.filter(Model.global_model_id == global_model.id)
|
||||
.all()
|
||||
)
|
||||
|
||||
provider_entries = []
|
||||
for model in models:
|
||||
provider = model.provider
|
||||
if not provider:
|
||||
continue
|
||||
|
||||
effective_tiered = model.get_effective_tiered_pricing()
|
||||
tier_count = len(effective_tiered.get("tiers", [])) if effective_tiered else 1
|
||||
|
||||
provider_entries.append(
|
||||
ModelCatalogProviderDetail(
|
||||
provider_id=provider.id,
|
||||
provider_name=provider.name,
|
||||
model_id=model.id,
|
||||
target_model=model.provider_model_name,
|
||||
input_price_per_1m=model.get_effective_input_price(),
|
||||
output_price_per_1m=model.get_effective_output_price(),
|
||||
cache_creation_price_per_1m=model.get_effective_cache_creation_price(),
|
||||
cache_read_price_per_1m=model.get_effective_cache_read_price(),
|
||||
cache_1h_creation_price_per_1m=model.get_effective_1h_cache_creation_price(),
|
||||
price_per_request=model.get_effective_price_per_request(),
|
||||
effective_tiered_pricing=effective_tiered,
|
||||
tier_count=tier_count,
|
||||
supports_vision=model.get_effective_supports_vision(),
|
||||
supports_function_calling=model.get_effective_supports_function_calling(),
|
||||
supports_streaming=model.get_effective_supports_streaming(),
|
||||
is_active=bool(model.is_active),
|
||||
)
|
||||
)
|
||||
|
||||
return GlobalModelProvidersResponse(
|
||||
providers=provider_entries,
|
||||
total=len(provider_entries),
|
||||
)
|
||||
541
_deprecated_py_src/api/admin/models/routing.py
Normal file
541
_deprecated_py_src/api/admin/models/routing.py
Normal file
@@ -0,0 +1,541 @@
|
||||
"""
|
||||
GlobalModel 请求链路预览 API
|
||||
|
||||
提供模型的请求链路信息,包括:
|
||||
- 请求会流向哪些提供商
|
||||
- 每个提供商的优先级和负载均衡配置
|
||||
- 模型名称映射关系
|
||||
- Key 的并发配置和健康状态
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from sqlalchemy.orm import Session, selectinload
|
||||
|
||||
from src.api.base.admin_adapter import AdminApiAdapter
|
||||
from src.api.base.context import ApiRequestContext
|
||||
from src.api.base.pipeline import get_pipeline
|
||||
from src.core.crypto import CryptoService
|
||||
from src.core.model_permissions import (
|
||||
check_model_allowed_with_mappings,
|
||||
parse_allowed_models_to_list,
|
||||
)
|
||||
from src.database import get_db
|
||||
from src.models.database import (
|
||||
GlobalModel,
|
||||
Model,
|
||||
Provider,
|
||||
ProviderAPIKey,
|
||||
ProviderEndpoint,
|
||||
)
|
||||
from src.services.scheduling.aware_scheduler import CacheAwareScheduler
|
||||
from src.services.system.config import SystemConfigService
|
||||
|
||||
router = APIRouter(prefix="/global", tags=["Admin - Global Models"])
|
||||
pipeline = get_pipeline()
|
||||
|
||||
|
||||
# ========== Response Models ==========
|
||||
|
||||
|
||||
class RoutingKeyInfo(BaseModel):
|
||||
"""Key 路由信息"""
|
||||
|
||||
id: str
|
||||
name: str
|
||||
masked_key: str = Field("", description="脱敏的 API Key")
|
||||
internal_priority: int = Field(..., description="Key 内部优先级")
|
||||
global_priority_by_format: dict[str, int] | None = Field(
|
||||
None, description="按 API 格式的全局优先级"
|
||||
)
|
||||
rpm_limit: int | None = Field(None, description="RPM 限制,null 表示自适应")
|
||||
is_adaptive: bool = Field(False, description="是否为自适应 RPM 模式")
|
||||
effective_rpm: int | None = Field(None, description="有效 RPM 限制")
|
||||
cache_ttl_minutes: int = Field(0, description="缓存 TTL(分钟)")
|
||||
health_score: float = Field(1.0, description="健康度分数(0-1 小数格式)")
|
||||
is_active: bool
|
||||
api_formats: list[str] = Field(default_factory=list, description="支持的 API 格式")
|
||||
# 模型白名单
|
||||
allowed_models: list[str] | None = Field(None, description="允许的模型列表,null 表示不限制")
|
||||
# 熔断状态
|
||||
circuit_breaker_open: bool = Field(False, description="熔断器是否打开")
|
||||
circuit_breaker_formats: list[str] = Field(
|
||||
default_factory=list, description="熔断的 API 格式列表"
|
||||
)
|
||||
next_probe_at: str | None = Field(None, description="下次探测时间(ISO格式)")
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class RoutingEndpointInfo(BaseModel):
|
||||
"""Endpoint 路由信息"""
|
||||
|
||||
id: str
|
||||
api_format: str
|
||||
base_url: str
|
||||
custom_path: str | None = None
|
||||
is_active: bool
|
||||
keys: list[RoutingKeyInfo] = Field(default_factory=list)
|
||||
total_keys: int = 0
|
||||
active_keys: int = 0
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class RoutingModelMapping(BaseModel):
|
||||
"""模型名称映射信息"""
|
||||
|
||||
name: str = Field(..., description="映射名称")
|
||||
priority: int = Field(..., description="优先级(数字越小优先级越高)")
|
||||
api_formats: list[str] | None = Field(None, description="作用域(适用的 API 格式)")
|
||||
|
||||
|
||||
class RoutingProviderInfo(BaseModel):
|
||||
"""Provider 路由信息"""
|
||||
|
||||
id: str
|
||||
name: str
|
||||
model_id: str = Field(..., description="Model ID(GlobalModel 与 Provider 的关联记录 ID)")
|
||||
provider_priority: int = Field(..., description="提供商优先级(数字越小优先级越高)")
|
||||
billing_type: str | None = Field(None, description="计费类型")
|
||||
monthly_quota_usd: float | None = Field(None, description="月额度(美元)")
|
||||
monthly_used_usd: float | None = Field(None, description="已用额度(美元)")
|
||||
is_active: bool
|
||||
# 模型映射信息
|
||||
provider_model_name: str = Field(..., description="提供商侧的模型名称")
|
||||
model_mappings: list[RoutingModelMapping] = Field(
|
||||
default_factory=list, description="模型名称映射列表"
|
||||
)
|
||||
model_is_active: bool = Field(True, description="Model 是否活跃")
|
||||
# Endpoint 和 Key 信息
|
||||
endpoints: list[RoutingEndpointInfo] = Field(default_factory=list)
|
||||
total_endpoints: int = 0
|
||||
active_endpoints: int = 0
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class GlobalKeyWhitelistItem(BaseModel):
|
||||
"""全局 Key 白名单项(用于前端实时匹配)"""
|
||||
|
||||
key_id: str = Field(..., description="Key ID")
|
||||
key_name: str = Field(..., description="Key 名称")
|
||||
masked_key: str = Field(..., description="脱敏的 API Key")
|
||||
provider_id: str = Field(..., description="Provider ID")
|
||||
provider_name: str = Field(..., description="Provider 名称")
|
||||
allowed_models: list[str] = Field(default_factory=list, description="Key 白名单模型列表")
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class ModelRoutingPreviewResponse(BaseModel):
|
||||
"""模型请求链路预览响应"""
|
||||
|
||||
global_model_id: str
|
||||
global_model_name: str
|
||||
display_name: str
|
||||
is_active: bool
|
||||
# GlobalModel 的模型映射(用于前端匹配 Key 白名单)
|
||||
global_model_mappings: list[str] = Field(
|
||||
default_factory=list, description="GlobalModel 的模型映射规则(正则模式)"
|
||||
)
|
||||
# 链路信息
|
||||
providers: list[RoutingProviderInfo] = Field(
|
||||
default_factory=list, description="按优先级排序的提供商列表"
|
||||
)
|
||||
total_providers: int = 0
|
||||
active_providers: int = 0
|
||||
# 调度配置
|
||||
scheduling_mode: str = Field("cache_affinity", description="调度模式")
|
||||
priority_mode: str = Field("provider", description="优先级模式")
|
||||
# 全局 Key 白名单数据(供前端实时匹配,包含所有 Provider 的 Key)
|
||||
all_keys_whitelist: list[GlobalKeyWhitelistItem] = Field(
|
||||
default_factory=list, description="所有 Provider 的 Key 白名单数据"
|
||||
)
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
# ========== API Endpoints ==========
|
||||
|
||||
|
||||
@router.get("/{global_model_id}/routing", response_model=ModelRoutingPreviewResponse)
|
||||
async def get_model_routing_preview(
|
||||
request: Request,
|
||||
global_model_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
) -> ModelRoutingPreviewResponse:
|
||||
"""
|
||||
获取模型请求链路预览
|
||||
|
||||
查看指定 GlobalModel 的完整请求链路信息,包括:
|
||||
- 关联的所有提供商及其优先级
|
||||
- 每个提供商的模型名称映射配置
|
||||
- Endpoint 和 Key 的详细配置
|
||||
- 负载均衡和调度策略
|
||||
|
||||
**路径参数**:
|
||||
- `global_model_id`: GlobalModel ID
|
||||
|
||||
**返回字段**:
|
||||
- `global_model_id`: GlobalModel ID
|
||||
- `global_model_name`: 模型名称
|
||||
- `display_name`: 显示名称
|
||||
- `is_active`: 是否活跃
|
||||
- `providers`: 按优先级排序的提供商列表,每个包含:
|
||||
- `id`: Provider ID
|
||||
- `name`: Provider 名称
|
||||
- `provider_priority`: 提供商优先级
|
||||
- `provider_model_name`: 提供商侧的模型名称
|
||||
- `model_mappings`: 模型名称映射列表
|
||||
- `endpoints`: Endpoint 列表,每个包含 Key 信息
|
||||
- `scheduling_mode`: 调度模式(cache_affinity, fixed_order, load_balance)
|
||||
- `priority_mode`: 优先级模式(provider, global_key)
|
||||
"""
|
||||
adapter = AdminGetModelRoutingPreviewAdapter(global_model_id=global_model_id)
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
|
||||
# ========== Adapters ==========
|
||||
|
||||
|
||||
@dataclass
|
||||
class AdminGetModelRoutingPreviewAdapter(AdminApiAdapter):
|
||||
"""获取模型请求链路预览"""
|
||||
|
||||
global_model_id: str
|
||||
|
||||
async def handle(self, context: ApiRequestContext) -> ModelRoutingPreviewResponse: # type: ignore[override]
|
||||
db = context.db
|
||||
|
||||
# 获取 GlobalModel
|
||||
global_model = db.query(GlobalModel).filter(GlobalModel.id == self.global_model_id).first()
|
||||
if not global_model:
|
||||
from fastapi import HTTPException
|
||||
|
||||
raise HTTPException(status_code=404, detail="GlobalModel not found")
|
||||
|
||||
# 获取所有关联的 Model(包含 Provider 信息)
|
||||
models = (
|
||||
db.query(Model)
|
||||
.options(selectinload(Model.provider))
|
||||
.filter(Model.global_model_id == global_model.id)
|
||||
.all()
|
||||
)
|
||||
|
||||
# 获取所有相关的 Provider ID
|
||||
provider_ids = [m.provider_id for m in models if m.provider_id]
|
||||
|
||||
# 批量获取 Provider 的 Endpoints
|
||||
endpoints_by_provider: dict[str, list[ProviderEndpoint]] = {}
|
||||
if provider_ids:
|
||||
endpoints = (
|
||||
db.query(ProviderEndpoint)
|
||||
.filter(ProviderEndpoint.provider_id.in_(provider_ids))
|
||||
.all()
|
||||
)
|
||||
for ep in endpoints:
|
||||
if ep.provider_id not in endpoints_by_provider:
|
||||
endpoints_by_provider[ep.provider_id] = []
|
||||
endpoints_by_provider[ep.provider_id].append(ep)
|
||||
|
||||
# 批量获取 Provider 的 Keys
|
||||
keys_by_provider: dict[str, list[ProviderAPIKey]] = {}
|
||||
if provider_ids:
|
||||
keys = (
|
||||
db.query(ProviderAPIKey).filter(ProviderAPIKey.provider_id.in_(provider_ids)).all()
|
||||
)
|
||||
for key in keys:
|
||||
if key.provider_id not in keys_by_provider:
|
||||
keys_by_provider[key.provider_id] = []
|
||||
keys_by_provider[key.provider_id].append(key)
|
||||
|
||||
# 提取 GlobalModel 的 model_mappings(用于 Key 白名单匹配)
|
||||
global_model_mappings: list[str] = []
|
||||
if global_model.config and isinstance(global_model.config, dict):
|
||||
mappings = global_model.config.get("model_mappings")
|
||||
if isinstance(mappings, list):
|
||||
global_model_mappings = [m for m in mappings if isinstance(m, str)]
|
||||
|
||||
# 构建 Provider 路由信息
|
||||
provider_infos: list[RoutingProviderInfo] = []
|
||||
for model in models:
|
||||
provider = model.provider
|
||||
if not provider:
|
||||
continue
|
||||
|
||||
# 获取模型映射
|
||||
model_mappings = []
|
||||
if model.provider_model_mappings:
|
||||
for mapping in model.provider_model_mappings:
|
||||
model_mappings.append(
|
||||
RoutingModelMapping(
|
||||
name=mapping.get("name", ""),
|
||||
priority=mapping.get("priority", 0),
|
||||
api_formats=mapping.get("api_formats"),
|
||||
)
|
||||
)
|
||||
|
||||
# 获取 Endpoints
|
||||
provider_endpoints = endpoints_by_provider.get(provider.id, [])
|
||||
provider_keys = keys_by_provider.get(provider.id, [])
|
||||
|
||||
# 按 api_format 组织 Keys
|
||||
keys_by_endpoint: dict[str, list[ProviderAPIKey]] = {}
|
||||
for key in provider_keys:
|
||||
# 每个 Key 可能支持多个 api_formats
|
||||
for fmt in key.api_formats or []:
|
||||
if fmt not in keys_by_endpoint:
|
||||
keys_by_endpoint[fmt] = []
|
||||
keys_by_endpoint[fmt].append(key)
|
||||
|
||||
# 定义 Key 模型权限匹配检查函数(用于过滤 Key)
|
||||
def is_key_model_allowed(key: ProviderAPIKey) -> bool:
|
||||
"""检查 Key 的白名单是否匹配当前 GlobalModel"""
|
||||
raw_allowed_models = key.allowed_models
|
||||
if not raw_allowed_models:
|
||||
# 没有白名单限制,允许所有模型
|
||||
return True
|
||||
allowed_models_list = parse_allowed_models_to_list(raw_allowed_models)
|
||||
is_allowed, _ = check_model_allowed_with_mappings(
|
||||
model_name=global_model.name,
|
||||
allowed_models=allowed_models_list,
|
||||
model_mappings=global_model_mappings,
|
||||
)
|
||||
return is_allowed
|
||||
|
||||
endpoint_infos = []
|
||||
for ep in provider_endpoints:
|
||||
# 获取该 Endpoint 格式对应的 Keys
|
||||
ep_keys = keys_by_endpoint.get(ep.api_format or "", [])
|
||||
|
||||
# 过滤:只保留白名单匹配当前 GlobalModel 的 Keys
|
||||
ep_keys = [k for k in ep_keys if is_key_model_allowed(k)]
|
||||
|
||||
# 如果该 Endpoint 没有任何匹配的 Key,跳过此 Endpoint
|
||||
if not ep_keys:
|
||||
continue
|
||||
|
||||
# 按优先级排序(使用当前格式的全局优先级)
|
||||
api_format = ep.api_format or ""
|
||||
|
||||
def get_key_priority(k: ProviderAPIKey) -> tuple[int, int]:
|
||||
format_priority = 999
|
||||
if k.global_priority_by_format and api_format in k.global_priority_by_format:
|
||||
format_priority = k.global_priority_by_format[api_format]
|
||||
return (format_priority, k.internal_priority or 0)
|
||||
|
||||
ep_keys.sort(key=get_key_priority)
|
||||
|
||||
key_infos = []
|
||||
for key in ep_keys:
|
||||
# 计算有效 RPM
|
||||
effective_rpm = key.rpm_limit
|
||||
is_adaptive = key.rpm_limit is None
|
||||
if is_adaptive and key.learned_rpm_limit:
|
||||
effective_rpm = key.learned_rpm_limit
|
||||
|
||||
# 从 health_by_format 获取健康度(0-1 小数格式)
|
||||
health_score = 1.0
|
||||
if key.health_by_format and ep.api_format:
|
||||
format_health = key.health_by_format.get(ep.api_format, {})
|
||||
health_score = format_health.get("health_score", 1.0)
|
||||
|
||||
# 生成脱敏 SK(先解密再脱敏)
|
||||
masked_key = ""
|
||||
if key.api_key:
|
||||
crypto = CryptoService()
|
||||
try:
|
||||
decrypted_key = crypto.decrypt(key.api_key, silent=True)
|
||||
except Exception:
|
||||
# 解密失败时使用加密后的值(可能是未加密的旧数据)
|
||||
decrypted_key = key.api_key
|
||||
if len(decrypted_key) > 8:
|
||||
masked_key = f"{decrypted_key[:4]}***{decrypted_key[-4:]}"
|
||||
else:
|
||||
masked_key = f"{decrypted_key[:2]}***"
|
||||
|
||||
# 检查熔断状态
|
||||
circuit_breaker_open = False
|
||||
circuit_breaker_formats: list[str] = []
|
||||
next_probe_at: str | None = None
|
||||
if key.circuit_breaker_by_format:
|
||||
for fmt, cb_state in key.circuit_breaker_by_format.items():
|
||||
if isinstance(cb_state, dict) and cb_state.get("open"):
|
||||
circuit_breaker_open = True
|
||||
circuit_breaker_formats.append(fmt)
|
||||
# 取最早的探测时间
|
||||
fmt_next_probe = cb_state.get("next_probe_at")
|
||||
if fmt_next_probe:
|
||||
if next_probe_at is None or fmt_next_probe < next_probe_at:
|
||||
next_probe_at = fmt_next_probe
|
||||
|
||||
# 解析 allowed_models
|
||||
raw_allowed_models = key.allowed_models
|
||||
allowed_models_list = (
|
||||
parse_allowed_models_to_list(raw_allowed_models)
|
||||
if raw_allowed_models
|
||||
else None
|
||||
)
|
||||
|
||||
key_infos.append(
|
||||
RoutingKeyInfo(
|
||||
id=key.id or "",
|
||||
name=key.name or "",
|
||||
masked_key=masked_key,
|
||||
internal_priority=key.internal_priority or 0,
|
||||
global_priority_by_format=key.global_priority_by_format,
|
||||
rpm_limit=key.rpm_limit,
|
||||
is_adaptive=is_adaptive,
|
||||
effective_rpm=effective_rpm,
|
||||
cache_ttl_minutes=key.cache_ttl_minutes or 0,
|
||||
health_score=health_score,
|
||||
is_active=bool(key.is_active),
|
||||
api_formats=key.api_formats or [],
|
||||
allowed_models=allowed_models_list,
|
||||
circuit_breaker_open=circuit_breaker_open,
|
||||
circuit_breaker_formats=circuit_breaker_formats,
|
||||
next_probe_at=next_probe_at,
|
||||
)
|
||||
)
|
||||
|
||||
# 计算有效 Keys 数量:is_active 即可(模型权限已在前面过滤)
|
||||
active_keys = sum(1 for k in key_infos if k.is_active)
|
||||
endpoint_infos.append(
|
||||
RoutingEndpointInfo(
|
||||
id=ep.id or "",
|
||||
api_format=ep.api_format or "",
|
||||
base_url=ep.base_url or "",
|
||||
custom_path=ep.custom_path,
|
||||
is_active=bool(ep.is_active),
|
||||
keys=key_infos,
|
||||
total_keys=len(key_infos),
|
||||
active_keys=active_keys,
|
||||
)
|
||||
)
|
||||
|
||||
# 按 endpoint signature 的推荐顺序排序 Endpoints(与前端展示保持一致)
|
||||
preferred_order = [
|
||||
"openai:chat",
|
||||
"openai:cli",
|
||||
"openai:compact",
|
||||
"openai:video",
|
||||
"claude:chat",
|
||||
"claude:cli",
|
||||
"gemini:chat",
|
||||
"gemini:cli",
|
||||
"gemini:video",
|
||||
]
|
||||
order_map = {key: i for i, key in enumerate(preferred_order)}
|
||||
endpoint_infos.sort(
|
||||
key=lambda e: order_map.get(str(e.api_format or "").strip().lower(), 999)
|
||||
)
|
||||
|
||||
active_endpoints = sum(1 for e in endpoint_infos if e.is_active)
|
||||
provider_infos.append(
|
||||
RoutingProviderInfo(
|
||||
id=provider.id,
|
||||
name=provider.name,
|
||||
model_id=model.id,
|
||||
provider_priority=provider.provider_priority,
|
||||
billing_type=provider.billing_type,
|
||||
monthly_quota_usd=provider.monthly_quota_usd,
|
||||
monthly_used_usd=provider.monthly_used_usd,
|
||||
is_active=bool(provider.is_active),
|
||||
provider_model_name=model.provider_model_name,
|
||||
model_mappings=model_mappings,
|
||||
model_is_active=bool(model.is_active),
|
||||
endpoints=endpoint_infos,
|
||||
total_endpoints=len(endpoint_infos),
|
||||
active_endpoints=active_endpoints,
|
||||
)
|
||||
)
|
||||
|
||||
# 按 provider_priority 排序
|
||||
provider_infos.sort(key=lambda p: p.provider_priority)
|
||||
|
||||
active_providers = sum(1 for p in provider_infos if p.is_active and p.model_is_active)
|
||||
|
||||
# 从数据库获取当前调度配置
|
||||
scheduling_mode = (
|
||||
SystemConfigService.get_config(
|
||||
db,
|
||||
"scheduling_mode",
|
||||
CacheAwareScheduler.SCHEDULING_MODE_CACHE_AFFINITY,
|
||||
)
|
||||
or CacheAwareScheduler.SCHEDULING_MODE_CACHE_AFFINITY
|
||||
)
|
||||
priority_mode = (
|
||||
SystemConfigService.get_config(
|
||||
db,
|
||||
"provider_priority_mode",
|
||||
CacheAwareScheduler.PRIORITY_MODE_PROVIDER,
|
||||
)
|
||||
or CacheAwareScheduler.PRIORITY_MODE_PROVIDER
|
||||
)
|
||||
|
||||
# 获取所有活跃 Provider 的 Key 白名单数据(供前端实时匹配)
|
||||
all_keys_whitelist: list[GlobalKeyWhitelistItem] = []
|
||||
crypto = CryptoService()
|
||||
|
||||
# 获取所有活跃的 Key(带白名单),使用 selectinload 避免 N+1 查询
|
||||
all_keys = (
|
||||
db.query(ProviderAPIKey)
|
||||
.join(Provider, ProviderAPIKey.provider_id == Provider.id)
|
||||
.options(selectinload(ProviderAPIKey.provider))
|
||||
.filter(ProviderAPIKey.is_active == True)
|
||||
.filter(Provider.is_active == True)
|
||||
.filter(ProviderAPIKey.allowed_models.isnot(None)) # 只获取有白名单的 Key
|
||||
.all()
|
||||
)
|
||||
|
||||
# 转换为白名单数据
|
||||
for key in all_keys:
|
||||
if not key.allowed_models:
|
||||
continue
|
||||
|
||||
# 解析白名单
|
||||
allowed_models_list = parse_allowed_models_to_list(key.allowed_models)
|
||||
if not allowed_models_list:
|
||||
continue
|
||||
|
||||
# 生成脱敏 Key
|
||||
masked = ""
|
||||
if key.api_key:
|
||||
try:
|
||||
decrypted = crypto.decrypt(key.api_key, silent=True)
|
||||
except Exception:
|
||||
decrypted = key.api_key
|
||||
if len(decrypted) > 8:
|
||||
masked = f"{decrypted[:4]}***{decrypted[-4:]}"
|
||||
else:
|
||||
masked = f"{decrypted[:2]}***"
|
||||
|
||||
all_keys_whitelist.append(
|
||||
GlobalKeyWhitelistItem(
|
||||
key_id=key.id or "",
|
||||
key_name=key.name or "",
|
||||
masked_key=masked,
|
||||
provider_id=key.provider_id or "",
|
||||
provider_name=key.provider.name if key.provider else "",
|
||||
allowed_models=allowed_models_list,
|
||||
)
|
||||
)
|
||||
|
||||
return ModelRoutingPreviewResponse(
|
||||
global_model_id=global_model.id,
|
||||
global_model_name=global_model.name,
|
||||
display_name=global_model.display_name,
|
||||
is_active=bool(global_model.is_active),
|
||||
global_model_mappings=global_model_mappings,
|
||||
providers=provider_infos,
|
||||
total_providers=len(provider_infos),
|
||||
active_providers=active_providers,
|
||||
scheduling_mode=scheduling_mode,
|
||||
priority_mode=priority_mode,
|
||||
all_keys_whitelist=all_keys_whitelist,
|
||||
)
|
||||
202
_deprecated_py_src/api/admin/modules.py
Normal file
202
_deprecated_py_src/api/admin/modules.py
Normal file
@@ -0,0 +1,202 @@
|
||||
"""模块管理 API 端点"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from src.api.base.admin_adapter import AdminApiAdapter
|
||||
from src.api.base.context import ApiRequestContext
|
||||
from src.api.base.pipeline import get_pipeline
|
||||
from src.core.exceptions import InvalidRequestException, NotFoundException
|
||||
from src.core.modules import ModuleStatus, get_module_registry
|
||||
from src.database import get_db
|
||||
|
||||
router = APIRouter(prefix="/api/admin/modules", tags=["Admin - Modules"])
|
||||
pipeline = get_pipeline()
|
||||
|
||||
|
||||
# ========== Response Models ==========
|
||||
|
||||
|
||||
class ModuleStatusResponse(BaseModel):
|
||||
"""模块状态响应"""
|
||||
|
||||
name: str
|
||||
available: bool
|
||||
enabled: bool
|
||||
active: bool
|
||||
config_validated: bool
|
||||
config_error: str | None
|
||||
display_name: str
|
||||
description: str
|
||||
category: str
|
||||
admin_route: str | None
|
||||
admin_menu_icon: str | None
|
||||
admin_menu_group: str | None
|
||||
admin_menu_order: int
|
||||
health: str
|
||||
|
||||
@classmethod
|
||||
def from_status(cls, status: ModuleStatus) -> ModuleStatusResponse:
|
||||
return cls(
|
||||
name=status.name,
|
||||
available=status.available,
|
||||
enabled=status.enabled,
|
||||
active=status.active,
|
||||
config_validated=status.config_validated,
|
||||
config_error=status.config_error,
|
||||
display_name=status.display_name,
|
||||
description=status.description,
|
||||
category=status.category.value,
|
||||
admin_route=status.admin_route,
|
||||
admin_menu_icon=status.admin_menu_icon,
|
||||
admin_menu_group=status.admin_menu_group,
|
||||
admin_menu_order=status.admin_menu_order,
|
||||
health=status.health.value,
|
||||
)
|
||||
|
||||
|
||||
class SetModuleEnabledRequest(BaseModel):
|
||||
"""设置模块启用状态请求"""
|
||||
|
||||
enabled: bool
|
||||
|
||||
|
||||
# ========== API Endpoints ==========
|
||||
|
||||
|
||||
@router.get("/status")
|
||||
async def get_all_modules_status(request: Request, db: Session = Depends(get_db)) -> Any:
|
||||
"""
|
||||
获取所有模块状态
|
||||
|
||||
返回系统中所有已注册模块的状态信息,包括可用性、启用状态等。
|
||||
需要管理员权限。
|
||||
|
||||
**返回字段**:
|
||||
- 模块名称到状态的映射字典
|
||||
"""
|
||||
adapter = AdminGetAllModulesStatusAdapter()
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
|
||||
@router.get("/status/{module_name}")
|
||||
async def get_module_status(
|
||||
module_name: str, request: Request, db: Session = Depends(get_db)
|
||||
) -> Any:
|
||||
"""
|
||||
获取单个模块状态
|
||||
|
||||
获取指定模块的详细状态信息。需要管理员权限。
|
||||
|
||||
**路径参数**:
|
||||
- `module_name`: 模块名称
|
||||
|
||||
**返回字段**:
|
||||
- 模块状态详情
|
||||
"""
|
||||
adapter = AdminGetModuleStatusAdapter(module_name=module_name)
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
|
||||
@router.put("/status/{module_name}/enabled")
|
||||
async def set_module_enabled(
|
||||
module_name: str, request: Request, db: Session = Depends(get_db)
|
||||
) -> Any:
|
||||
"""
|
||||
设置模块启用状态
|
||||
|
||||
启用或禁用指定模块。需要管理员权限。
|
||||
注意:只有 available=true 的模块才能被启用。
|
||||
|
||||
**路径参数**:
|
||||
- `module_name`: 模块名称
|
||||
|
||||
**请求体**:
|
||||
- `enabled`: 是否启用
|
||||
|
||||
**返回字段**:
|
||||
- 更新后的模块状态
|
||||
"""
|
||||
adapter = AdminSetModuleEnabledAdapter(module_name=module_name)
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
|
||||
# ========== Adapters ==========
|
||||
|
||||
|
||||
class AdminGetAllModulesStatusAdapter(AdminApiAdapter):
|
||||
"""获取所有模块状态"""
|
||||
|
||||
async def handle(self, context: ApiRequestContext) -> dict[str, Any]:
|
||||
registry = get_module_registry()
|
||||
all_status = await registry.get_all_status_async(context.db)
|
||||
|
||||
return {
|
||||
name: ModuleStatusResponse.from_status(status).model_dump()
|
||||
for name, status in all_status.items()
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class AdminGetModuleStatusAdapter(AdminApiAdapter):
|
||||
"""获取单个模块状态"""
|
||||
|
||||
module_name: str
|
||||
|
||||
async def handle(self, context: ApiRequestContext) -> dict[str, Any]:
|
||||
registry = get_module_registry()
|
||||
status = await registry.get_module_status_async(self.module_name, context.db)
|
||||
|
||||
if status is None:
|
||||
raise NotFoundException(f"模块 '{self.module_name}' 不存在")
|
||||
|
||||
return ModuleStatusResponse.from_status(status).model_dump()
|
||||
|
||||
|
||||
@dataclass
|
||||
class AdminSetModuleEnabledAdapter(AdminApiAdapter):
|
||||
"""设置模块启用状态"""
|
||||
|
||||
module_name: str
|
||||
|
||||
async def handle(self, context: ApiRequestContext) -> dict[str, Any]:
|
||||
registry = get_module_registry()
|
||||
|
||||
# 检查模块是否存在
|
||||
module = registry.get_module(self.module_name)
|
||||
if module is None:
|
||||
raise NotFoundException(f"模块 '{self.module_name}' 不存在")
|
||||
|
||||
# 检查模块是否可用
|
||||
if not registry.is_available(self.module_name):
|
||||
raise InvalidRequestException(
|
||||
f"模块 '{self.module_name}' 不可用,无法启用。"
|
||||
f"请检查环境变量 {module.metadata.env_key} 和依赖库。"
|
||||
)
|
||||
|
||||
# 解析请求体
|
||||
payload = context.ensure_json_body()
|
||||
try:
|
||||
req = SetModuleEnabledRequest.model_validate(payload)
|
||||
except Exception:
|
||||
raise InvalidRequestException("请求体格式错误,需要 enabled 字段")
|
||||
|
||||
# 如果是启用模块,必须先通过配置验证
|
||||
if req.enabled:
|
||||
config_validated, config_error = registry.validate_config(self.module_name, context.db)
|
||||
if not config_validated:
|
||||
raise InvalidRequestException(f"模块配置未验证通过: {config_error}")
|
||||
|
||||
# 设置启用状态
|
||||
registry.set_enabled(self.module_name, req.enabled, context.db)
|
||||
|
||||
# 返回更新后的状态(模块已在上面检查存在,此处必定返回非 None)
|
||||
status = registry.get_module_status(self.module_name, context.db)
|
||||
assert status is not None
|
||||
return ModuleStatusResponse.from_status(status).model_dump()
|
||||
14
_deprecated_py_src/api/admin/monitoring/__init__.py
Normal file
14
_deprecated_py_src/api/admin/monitoring/__init__.py
Normal file
@@ -0,0 +1,14 @@
|
||||
"""Admin monitoring router合集。"""
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from .audit import router as audit_router
|
||||
from .cache import router as cache_router
|
||||
from .trace import router as trace_router
|
||||
|
||||
router = APIRouter()
|
||||
router.include_router(audit_router)
|
||||
router.include_router(cache_router)
|
||||
router.include_router(trace_router)
|
||||
|
||||
__all__ = ["router"]
|
||||
551
_deprecated_py_src/api/admin/monitoring/audit.py
Normal file
551
_deprecated_py_src/api/admin/monitoring/audit.py
Normal file
@@ -0,0 +1,551 @@
|
||||
"""管理员监控与审计端点。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||
from sqlalchemy import case, func
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from src.api.base.admin_adapter import AdminApiAdapter
|
||||
from src.api.base.context import ApiRequestContext
|
||||
from src.api.base.pagination import PaginationMeta, build_pagination_payload
|
||||
from src.api.base.pipeline import get_pipeline
|
||||
from src.config.constants import CacheTTL
|
||||
from src.core.logger import logger
|
||||
from src.database import get_db
|
||||
from src.models.database import (
|
||||
ApiKey,
|
||||
AuditEventType,
|
||||
AuditLog,
|
||||
Provider,
|
||||
Usage,
|
||||
)
|
||||
from src.models.database import User as DBUser
|
||||
from src.services.health.monitor import HealthMonitor
|
||||
from src.services.system.audit import audit_service
|
||||
from src.utils.cache_decorator import cache_result
|
||||
from src.utils.database_helpers import escape_like_pattern
|
||||
|
||||
router = APIRouter(prefix="/api/admin/monitoring", tags=["Admin - Monitoring"])
|
||||
pipeline = get_pipeline()
|
||||
|
||||
|
||||
@router.get("/audit-logs")
|
||||
async def get_audit_logs(
|
||||
request: Request,
|
||||
username: str | None = Query(None, description="用户名筛选 (模糊匹配)"),
|
||||
event_type: str | None = Query(None, description="事件类型筛选"),
|
||||
days: int = Query(7, description="查询天数"),
|
||||
limit: int = Query(100, description="返回数量限制"),
|
||||
offset: int = Query(0, description="偏移量"),
|
||||
db: Session = Depends(get_db),
|
||||
) -> Any:
|
||||
"""
|
||||
获取审计日志
|
||||
|
||||
获取系统审计日志列表,支持按用户名、事件类型、时间范围筛选。需要管理员权限。
|
||||
|
||||
**查询参数**:
|
||||
- `username`: 可选,用户名筛选(模糊匹配)
|
||||
- `event_type`: 可选,事件类型筛选
|
||||
- `days`: 查询最近多少天的日志,默认 7 天
|
||||
- `limit`: 返回数量限制,默认 100
|
||||
- `offset`: 分页偏移量,默认 0
|
||||
|
||||
**返回字段**:
|
||||
- `items`: 审计日志列表,每条日志包含:
|
||||
- `id`: 日志 ID
|
||||
- `event_type`: 事件类型
|
||||
- `user_id`: 用户 ID
|
||||
- `user_email`: 用户邮箱
|
||||
- `user_username`: 用户名
|
||||
- `description`: 事件描述
|
||||
- `ip_address`: IP 地址
|
||||
- `status_code`: HTTP 状态码
|
||||
- `error_message`: 错误信息
|
||||
- `metadata`: 事件元数据
|
||||
- `created_at`: 创建时间
|
||||
- `meta`: 分页元数据(total, limit, offset, count)
|
||||
- `filters`: 筛选条件
|
||||
"""
|
||||
adapter = AdminGetAuditLogsAdapter(
|
||||
username=username,
|
||||
event_type=event_type,
|
||||
days=days,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
|
||||
@router.get("/system-status")
|
||||
async def get_system_status(request: Request, db: Session = Depends(get_db)) -> Any:
|
||||
"""
|
||||
获取系统状态
|
||||
|
||||
获取系统当前的运行状态和关键指标。需要管理员权限。
|
||||
|
||||
**返回字段**:
|
||||
- `timestamp`: 当前时间戳
|
||||
- `users`: 用户统计(total: 总用户数, active: 活跃用户数)
|
||||
- `providers`: 提供商统计(total: 总提供商数, active: 活跃提供商数)
|
||||
- `api_keys`: API Key 统计(total: 总数, active: 活跃数)
|
||||
- `today_stats`: 今日统计(requests: 请求数, tokens: token 数, cost_usd: 成本)
|
||||
- `recent_errors`: 最近 1 小时内的错误数
|
||||
"""
|
||||
adapter = AdminSystemStatusAdapter()
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
|
||||
@router.get("/suspicious-activities")
|
||||
async def get_suspicious_activities(
|
||||
request: Request,
|
||||
hours: int = Query(24, description="时间范围(小时)"),
|
||||
db: Session = Depends(get_db),
|
||||
) -> Any:
|
||||
"""
|
||||
获取可疑活动记录
|
||||
|
||||
获取系统检测到的可疑活动记录。需要管理员权限。
|
||||
|
||||
**查询参数**:
|
||||
- `hours`: 时间范围(小时),默认 24 小时
|
||||
|
||||
**返回字段**:
|
||||
- `activities`: 可疑活动列表,每条记录包含:
|
||||
- `id`: 记录 ID
|
||||
- `event_type`: 事件类型
|
||||
- `user_id`: 用户 ID
|
||||
- `description`: 事件描述
|
||||
- `ip_address`: IP 地址
|
||||
- `metadata`: 事件元数据
|
||||
- `created_at`: 创建时间
|
||||
- `count`: 活动总数
|
||||
- `time_range_hours`: 查询的时间范围(小时)
|
||||
"""
|
||||
adapter = AdminSuspiciousActivitiesAdapter(hours=hours)
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
|
||||
@router.get("/user-behavior/{user_id}")
|
||||
async def analyze_user_behavior(
|
||||
user_id: str,
|
||||
request: Request,
|
||||
days: int = Query(30, description="分析天数"),
|
||||
db: Session = Depends(get_db),
|
||||
) -> Any:
|
||||
"""
|
||||
分析用户行为
|
||||
|
||||
分析指定用户的行为模式和使用情况。需要管理员权限。
|
||||
|
||||
**路径参数**:
|
||||
- `user_id`: 用户 ID
|
||||
|
||||
**查询参数**:
|
||||
- `days`: 分析最近多少天的数据,默认 30 天
|
||||
|
||||
**返回字段**:
|
||||
- 用户行为分析结果,包括活动频率、使用模式、异常行为等
|
||||
"""
|
||||
adapter = AdminUserBehaviorAdapter(user_id=user_id, days=days)
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
|
||||
@router.get("/resilience-status")
|
||||
async def get_resilience_status(request: Request, db: Session = Depends(get_db)) -> Any:
|
||||
"""
|
||||
获取韧性系统状态
|
||||
|
||||
获取系统韧性管理的当前状态,包括错误统计、熔断器状态等。需要管理员权限。
|
||||
|
||||
**返回字段**:
|
||||
- `timestamp`: 当前时间戳
|
||||
- `health_score`: 健康评分(0-100)
|
||||
- `status`: 系统状态(healthy: 健康,degraded: 降级,critical: 严重)
|
||||
- `error_statistics`: 错误统计信息
|
||||
- `recent_errors`: 最近的错误列表(最多 10 条)
|
||||
- `recommendations`: 系统建议
|
||||
"""
|
||||
adapter = AdminResilienceStatusAdapter()
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
|
||||
@router.delete("/resilience/error-stats")
|
||||
async def reset_error_stats(request: Request, db: Session = Depends(get_db)) -> None:
|
||||
"""
|
||||
重置错误统计
|
||||
|
||||
重置韧性系统的错误统计数据。需要管理员权限。
|
||||
|
||||
**返回字段**:
|
||||
- `message`: 操作结果信息
|
||||
- `previous_stats`: 重置前的统计数据
|
||||
- `reset_by`: 执行重置的管理员邮箱
|
||||
- `reset_at`: 重置时间
|
||||
"""
|
||||
adapter = AdminResetErrorStatsAdapter()
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
|
||||
@router.get("/resilience/circuit-history")
|
||||
async def get_circuit_history(
|
||||
request: Request,
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
db: Session = Depends(get_db),
|
||||
) -> Any:
|
||||
"""
|
||||
获取熔断器历史记录
|
||||
|
||||
获取熔断器的状态变更历史记录。需要管理员权限。
|
||||
|
||||
**查询参数**:
|
||||
- `limit`: 返回数量限制,默认 50,最大 200
|
||||
|
||||
**返回字段**:
|
||||
- `items`: 熔断器历史记录列表
|
||||
- `count`: 记录总数
|
||||
"""
|
||||
adapter = AdminCircuitHistoryAdapter(limit=limit)
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
|
||||
@dataclass
|
||||
class AdminGetAuditLogsAdapter(AdminApiAdapter):
|
||||
username: str | None
|
||||
event_type: str | None
|
||||
days: int
|
||||
limit: int
|
||||
offset: int
|
||||
|
||||
# 查看审计日志本身不应该产生审计记录,避免刷新页面时产生大量无意义的日志
|
||||
audit_log_enabled: bool = False
|
||||
|
||||
@cache_result(
|
||||
key_prefix="admin:monitoring:audit-logs",
|
||||
ttl=CacheTTL.ADMIN_USAGE_RECORDS,
|
||||
user_specific=False,
|
||||
vary_by=["username", "event_type", "days", "limit", "offset"],
|
||||
)
|
||||
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
||||
db = context.db
|
||||
cutoff_time = datetime.now(timezone.utc) - timedelta(days=self.days)
|
||||
|
||||
count_query = db.query(func.count(AuditLog.id)).filter(AuditLog.created_at >= cutoff_time)
|
||||
if self.username:
|
||||
escaped = escape_like_pattern(self.username)
|
||||
count_query = count_query.outerjoin(DBUser, AuditLog.user_id == DBUser.id).filter(
|
||||
DBUser.username.ilike(f"%{escaped}%", escape="\\")
|
||||
)
|
||||
if self.event_type:
|
||||
count_query = count_query.filter(AuditLog.event_type == self.event_type)
|
||||
total = int(count_query.scalar() or 0)
|
||||
|
||||
base_query = (
|
||||
db.query(AuditLog, DBUser)
|
||||
.outerjoin(DBUser, AuditLog.user_id == DBUser.id)
|
||||
.filter(AuditLog.created_at >= cutoff_time)
|
||||
)
|
||||
if self.username:
|
||||
escaped = escape_like_pattern(self.username)
|
||||
base_query = base_query.filter(DBUser.username.ilike(f"%{escaped}%", escape="\\"))
|
||||
if self.event_type:
|
||||
base_query = base_query.filter(AuditLog.event_type == self.event_type)
|
||||
|
||||
logs_with_users = (
|
||||
base_query.order_by(AuditLog.created_at.desc())
|
||||
.offset(self.offset)
|
||||
.limit(self.limit)
|
||||
.all()
|
||||
)
|
||||
|
||||
items = [
|
||||
{
|
||||
"id": log.id,
|
||||
"event_type": log.event_type,
|
||||
"user_id": log.user_id,
|
||||
"user_email": user.email if user else None,
|
||||
"user_username": user.username if user else None,
|
||||
"description": log.description,
|
||||
"ip_address": log.ip_address,
|
||||
"status_code": log.status_code,
|
||||
"error_message": log.error_message,
|
||||
"metadata": log.event_metadata,
|
||||
"created_at": log.created_at.isoformat() if log.created_at else None,
|
||||
}
|
||||
for log, user in logs_with_users
|
||||
]
|
||||
meta = PaginationMeta(
|
||||
total=total,
|
||||
limit=self.limit,
|
||||
offset=self.offset,
|
||||
count=len(items),
|
||||
)
|
||||
|
||||
payload = build_pagination_payload(
|
||||
items,
|
||||
meta,
|
||||
filters={
|
||||
"username": self.username,
|
||||
"event_type": self.event_type,
|
||||
"days": self.days,
|
||||
},
|
||||
)
|
||||
context.add_audit_metadata(
|
||||
action="monitor_audit_logs",
|
||||
filter_username=self.username,
|
||||
filter_event_type=self.event_type,
|
||||
days=self.days,
|
||||
limit=self.limit,
|
||||
offset=self.offset,
|
||||
total=total,
|
||||
result_count=meta.count,
|
||||
)
|
||||
return payload
|
||||
|
||||
|
||||
class AdminSystemStatusAdapter(AdminApiAdapter):
|
||||
@cache_result(
|
||||
key_prefix="admin:monitoring:system-status",
|
||||
ttl=CacheTTL.ADMIN_USAGE_RECORDS,
|
||||
user_specific=False,
|
||||
)
|
||||
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
||||
db = context.db
|
||||
|
||||
user_stats = db.query(
|
||||
func.count(DBUser.id).label("total"),
|
||||
func.sum(case((DBUser.is_active.is_(True), 1), else_=0)).label("active"),
|
||||
).first()
|
||||
total_users = int((user_stats.total if user_stats else 0) or 0)
|
||||
active_users = int((user_stats.active if user_stats else 0) or 0)
|
||||
|
||||
provider_stats = db.query(
|
||||
func.count(Provider.id).label("total"),
|
||||
func.sum(case((Provider.is_active.is_(True), 1), else_=0)).label("active"),
|
||||
).first()
|
||||
total_providers = int((provider_stats.total if provider_stats else 0) or 0)
|
||||
active_providers = int((provider_stats.active if provider_stats else 0) or 0)
|
||||
|
||||
api_key_stats = db.query(
|
||||
func.count(ApiKey.id).label("total"),
|
||||
func.sum(case((ApiKey.is_active.is_(True), 1), else_=0)).label("active"),
|
||||
).first()
|
||||
total_api_keys = int((api_key_stats.total if api_key_stats else 0) or 0)
|
||||
active_api_keys = int((api_key_stats.active if api_key_stats else 0) or 0)
|
||||
|
||||
today_start = datetime.now(timezone.utc).replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
today_stats = (
|
||||
db.query(
|
||||
func.count(Usage.id).label("requests"),
|
||||
func.coalesce(func.sum(Usage.total_tokens), 0).label("tokens"),
|
||||
func.coalesce(func.sum(Usage.total_cost_usd), 0.0).label("cost"),
|
||||
)
|
||||
.filter(Usage.created_at >= today_start)
|
||||
.first()
|
||||
)
|
||||
today_requests = int((today_stats.requests if today_stats else 0) or 0)
|
||||
today_tokens = int((today_stats.tokens if today_stats else 0) or 0)
|
||||
today_cost = float((today_stats.cost if today_stats else 0.0) or 0.0)
|
||||
|
||||
recent_errors = (
|
||||
db.query(func.count(AuditLog.id))
|
||||
.filter(
|
||||
AuditLog.event_type.in_(
|
||||
[
|
||||
AuditEventType.REQUEST_FAILED.value,
|
||||
AuditEventType.SUSPICIOUS_ACTIVITY.value,
|
||||
]
|
||||
),
|
||||
AuditLog.created_at >= datetime.now(timezone.utc) - timedelta(hours=1),
|
||||
)
|
||||
.scalar()
|
||||
or 0
|
||||
)
|
||||
|
||||
context.add_audit_metadata(
|
||||
action="system_status_snapshot",
|
||||
total_users=total_users,
|
||||
active_users=active_users,
|
||||
total_providers=total_providers,
|
||||
active_providers=active_providers,
|
||||
total_api_keys=total_api_keys,
|
||||
active_api_keys=active_api_keys,
|
||||
today_requests=today_requests,
|
||||
today_tokens=today_tokens,
|
||||
today_cost=today_cost,
|
||||
recent_errors=int(recent_errors or 0),
|
||||
)
|
||||
|
||||
return {
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"users": {"total": total_users, "active": active_users},
|
||||
"providers": {"total": total_providers, "active": active_providers},
|
||||
"api_keys": {"total": total_api_keys, "active": active_api_keys},
|
||||
"today_stats": {
|
||||
"requests": today_requests,
|
||||
"tokens": today_tokens,
|
||||
"cost_usd": f"${today_cost:.4f}",
|
||||
},
|
||||
"recent_errors": recent_errors,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class AdminSuspiciousActivitiesAdapter(AdminApiAdapter):
|
||||
hours: int
|
||||
|
||||
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
||||
db = context.db
|
||||
activities = audit_service.get_suspicious_activities(db=db, hours=self.hours, limit=100)
|
||||
response = {
|
||||
"activities": [
|
||||
{
|
||||
"id": activity.id,
|
||||
"event_type": activity.event_type,
|
||||
"user_id": activity.user_id,
|
||||
"description": activity.description,
|
||||
"ip_address": activity.ip_address,
|
||||
"metadata": activity.event_metadata,
|
||||
"created_at": activity.created_at.isoformat() if activity.created_at else None,
|
||||
}
|
||||
for activity in activities
|
||||
],
|
||||
"count": len(activities),
|
||||
"time_range_hours": self.hours,
|
||||
}
|
||||
context.add_audit_metadata(
|
||||
action="monitor_suspicious_activity",
|
||||
hours=self.hours,
|
||||
result_count=len(activities),
|
||||
)
|
||||
return response
|
||||
|
||||
|
||||
@dataclass
|
||||
class AdminUserBehaviorAdapter(AdminApiAdapter):
|
||||
user_id: str
|
||||
days: int
|
||||
|
||||
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
||||
result = audit_service.analyze_user_behavior(
|
||||
db=context.db,
|
||||
user_id=self.user_id,
|
||||
days=self.days,
|
||||
)
|
||||
context.add_audit_metadata(
|
||||
action="monitor_user_behavior",
|
||||
target_user_id=self.user_id,
|
||||
days=self.days,
|
||||
contains_summary=bool(result),
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
class AdminResilienceStatusAdapter(AdminApiAdapter):
|
||||
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
||||
try:
|
||||
from src.core.resilience import resilience_manager
|
||||
except ImportError as exc:
|
||||
raise HTTPException(status_code=503, detail="韧性管理系统未启用") from exc
|
||||
|
||||
error_stats = resilience_manager.get_error_stats()
|
||||
recent_errors = [
|
||||
{
|
||||
"error_id": info["error_id"],
|
||||
"error_type": info["error_type"],
|
||||
"operation": info["operation"],
|
||||
"timestamp": info["timestamp"].isoformat(),
|
||||
"context": info.get("context", {}),
|
||||
}
|
||||
for info in resilience_manager.last_errors[-10:]
|
||||
]
|
||||
|
||||
total_errors = error_stats.get("total_errors", 0)
|
||||
circuit_breakers = error_stats.get("circuit_breakers", {})
|
||||
circuit_breakers_open = sum(
|
||||
1 for status in circuit_breakers.values() if status.get("state") == "open"
|
||||
)
|
||||
health_score = max(0, 100 - (total_errors * 2) - (circuit_breakers_open * 20))
|
||||
|
||||
response = {
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"health_score": health_score,
|
||||
"status": (
|
||||
"healthy" if health_score > 80 else "degraded" if health_score > 50 else "critical"
|
||||
),
|
||||
"error_statistics": error_stats,
|
||||
"recent_errors": recent_errors,
|
||||
"recommendations": _get_health_recommendations(error_stats, health_score),
|
||||
}
|
||||
context.add_audit_metadata(
|
||||
action="resilience_status",
|
||||
health_score=health_score,
|
||||
error_total=error_stats.get("total_errors") if isinstance(error_stats, dict) else None,
|
||||
open_circuit_breakers=circuit_breakers_open,
|
||||
)
|
||||
return response
|
||||
|
||||
|
||||
class AdminResetErrorStatsAdapter(AdminApiAdapter):
|
||||
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
||||
try:
|
||||
from src.core.resilience import resilience_manager
|
||||
except ImportError as exc:
|
||||
raise HTTPException(status_code=503, detail="韧性管理系统未启用") from exc
|
||||
|
||||
old_stats = resilience_manager.get_error_stats()
|
||||
resilience_manager.error_stats.clear()
|
||||
resilience_manager.last_errors.clear()
|
||||
|
||||
logger.info(f"管理员 {context.user.email if context.user else 'unknown'} 重置了错误统计")
|
||||
|
||||
context.add_audit_metadata(
|
||||
action="reset_error_stats",
|
||||
previous_total_errors=(
|
||||
old_stats.get("total_errors") if isinstance(old_stats, dict) else None
|
||||
),
|
||||
)
|
||||
|
||||
return {
|
||||
"message": "错误统计已重置",
|
||||
"previous_stats": old_stats,
|
||||
"reset_by": context.user.email if context.user else None,
|
||||
"reset_at": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
|
||||
|
||||
class AdminCircuitHistoryAdapter(AdminApiAdapter):
|
||||
def __init__(self, limit: int = 50):
|
||||
super().__init__()
|
||||
self.limit = limit
|
||||
|
||||
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
||||
history = HealthMonitor.get_circuit_history(self.limit)
|
||||
context.add_audit_metadata(
|
||||
action="circuit_history",
|
||||
limit=self.limit,
|
||||
result_count=len(history),
|
||||
)
|
||||
return {"items": history, "count": len(history)}
|
||||
|
||||
|
||||
def _get_health_recommendations(error_stats: dict, health_score: int) -> list[str]:
|
||||
recommendations: list[str] = []
|
||||
if health_score < 50:
|
||||
recommendations.append("系统健康状况严重,请立即检查错误日志")
|
||||
if error_stats.get("total_errors", 0) > 100:
|
||||
recommendations.append("错误频率过高,建议检查系统配置和外部依赖")
|
||||
|
||||
circuit_breakers = error_stats.get("circuit_breakers", {})
|
||||
open_breakers = [k for k, v in circuit_breakers.items() if v.get("state") == "open"]
|
||||
if open_breakers:
|
||||
recommendations.append(f"以下服务熔断器已打开:{', '.join(open_breakers)}")
|
||||
|
||||
if health_score > 90:
|
||||
recommendations.append("系统运行良好")
|
||||
return recommendations
|
||||
1889
_deprecated_py_src/api/admin/monitoring/cache.py
Normal file
1889
_deprecated_py_src/api/admin/monitoring/cache.py
Normal file
File diff suppressed because it is too large
Load Diff
439
_deprecated_py_src/api/admin/monitoring/trace.py
Normal file
439
_deprecated_py_src/api/admin/monitoring/trace.py
Normal file
@@ -0,0 +1,439 @@
|
||||
"""
|
||||
请求链路追踪 API 端点
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from src.api.base.admin_adapter import AdminApiAdapter
|
||||
from src.api.base.context import ApiRequestContext
|
||||
from src.api.base.pipeline import get_pipeline
|
||||
from src.core.crypto import crypto_service
|
||||
from src.database import get_db
|
||||
from src.models.database import Provider, ProviderAPIKey, ProviderEndpoint
|
||||
from src.services.request.candidate import RequestCandidateService
|
||||
|
||||
router = APIRouter(prefix="/api/admin/monitoring/trace", tags=["Admin - Monitoring: Trace"])
|
||||
pipeline = get_pipeline()
|
||||
|
||||
|
||||
class CandidateResponse(BaseModel):
|
||||
"""候选记录响应"""
|
||||
|
||||
id: str
|
||||
request_id: str
|
||||
candidate_index: int
|
||||
retry_index: int = 0 # 重试序号(从0开始)
|
||||
provider_id: str | None = None
|
||||
provider_name: str | None = None
|
||||
provider_website: str | None = None # Provider 官网
|
||||
endpoint_id: str | None = None
|
||||
endpoint_name: str | None = None # 端点显示名称(api_format)
|
||||
key_id: str | None = None
|
||||
key_name: str | None = None # 密钥名称
|
||||
key_account_label: str | None = None # 更适合展示的测试账号标签(优先 OAuth 邮箱)
|
||||
key_preview: str | None = None # 密钥脱敏预览(如 sk-***abc),OAuth 类型不返回
|
||||
key_auth_type: str | None = None # 密钥认证类型(api_key, service_account, oauth)
|
||||
key_oauth_plan_type: str | None = None # OAuth 账号套餐类型(free/plus/team/enterprise)
|
||||
key_capabilities: dict | None = None # Key 支持的能力
|
||||
required_capabilities: dict | None = None # 请求实际需要的能力标签
|
||||
status: str # 'pending', 'success', 'failed', 'skipped'
|
||||
skip_reason: str | None = None
|
||||
is_cached: bool = False
|
||||
# 执行结果字段
|
||||
status_code: int | None = None
|
||||
error_type: str | None = None
|
||||
error_message: str | None = None
|
||||
latency_ms: int | None = None
|
||||
concurrent_requests: int | None = None
|
||||
extra_data: dict | None = None
|
||||
created_at: datetime
|
||||
started_at: datetime | None = None
|
||||
finished_at: datetime | None = None
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class RequestTraceResponse(BaseModel):
|
||||
"""请求追踪完整响应"""
|
||||
|
||||
request_id: str
|
||||
total_candidates: int
|
||||
final_status: str # 'success', 'failed', 'cancelled', 'streaming', 'pending'
|
||||
total_latency_ms: int
|
||||
candidates: list[CandidateResponse]
|
||||
|
||||
|
||||
@router.get("/{request_id}", response_model=RequestTraceResponse)
|
||||
async def get_request_trace(
|
||||
request_id: str,
|
||||
request: Request,
|
||||
attempted_only: bool = Query(False, description="仅返回实际尝试过的候选"),
|
||||
db: Session = Depends(get_db),
|
||||
) -> Any:
|
||||
"""
|
||||
获取请求的完整追踪信息
|
||||
|
||||
获取指定请求的完整链路追踪信息,包括所有候选(candidates)的执行情况。
|
||||
|
||||
**路径参数**:
|
||||
- `request_id`: 请求 ID
|
||||
|
||||
**返回字段**:
|
||||
- `request_id`: 请求 ID
|
||||
- `total_candidates`: 候选总数
|
||||
- `final_status`: 最终状态(success: 成功,failed: 失败,streaming: 流式传输中,pending: 等待中)
|
||||
- `total_latency_ms`: 总延迟(毫秒)
|
||||
- `candidates`: 候选列表,每个候选包含:
|
||||
- `id`: 候选 ID
|
||||
- `request_id`: 请求 ID
|
||||
- `candidate_index`: 候选索引
|
||||
- `retry_index`: 重试序号
|
||||
- `provider_id`: 提供商 ID
|
||||
- `provider_name`: 提供商名称
|
||||
- `provider_website`: 提供商官网
|
||||
- `endpoint_id`: 端点 ID
|
||||
- `endpoint_name`: 端点名称(API 格式)
|
||||
- `key_id`: 密钥 ID
|
||||
- `key_name`: 密钥名称
|
||||
- `key_preview`: 密钥脱敏预览
|
||||
- `key_capabilities`: 密钥支持的能力
|
||||
- `required_capabilities`: 请求需要的能力标签
|
||||
- `status`: 状态(pending, success, failed, skipped)
|
||||
- `skip_reason`: 跳过原因
|
||||
- `is_cached`: 是否缓存命中
|
||||
- `status_code`: HTTP 状态码
|
||||
- `error_type`: 错误类型
|
||||
- `error_message`: 错误信息
|
||||
- `latency_ms`: 延迟(毫秒)
|
||||
- `concurrent_requests`: 并发请求数
|
||||
- `extra_data`: 额外数据
|
||||
- `created_at`: 创建时间
|
||||
- `started_at`: 开始时间
|
||||
- `finished_at`: 完成时间
|
||||
"""
|
||||
|
||||
adapter = AdminGetRequestTraceAdapter(request_id=request_id, attempted_only=attempted_only)
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
|
||||
@router.get("/stats/provider/{provider_id}")
|
||||
async def get_provider_failure_rate(
|
||||
provider_id: str,
|
||||
request: Request,
|
||||
limit: int = Query(100, ge=1, le=1000, description="统计最近的尝试数量"),
|
||||
db: Session = Depends(get_db),
|
||||
) -> Any:
|
||||
"""
|
||||
获取提供商的失败率统计
|
||||
|
||||
获取指定提供商最近的失败率统计信息。需要管理员权限。
|
||||
|
||||
**路径参数**:
|
||||
- `provider_id`: 提供商 ID
|
||||
|
||||
**查询参数**:
|
||||
- `limit`: 统计最近的尝试数量,默认 100,最大 1000
|
||||
|
||||
**返回字段**:
|
||||
- `provider_id`: 提供商 ID
|
||||
- `total_attempts`: 总尝试次数
|
||||
- `success_count`: 成功次数
|
||||
- `failed_count`: 失败次数
|
||||
- `failure_rate`: 失败率(百分比)
|
||||
- `avg_latency_ms`: 平均延迟(毫秒)
|
||||
"""
|
||||
adapter = AdminProviderFailureRateAdapter(provider_id=provider_id, limit=limit)
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
|
||||
# -------- 请求追踪适配器 --------
|
||||
|
||||
|
||||
@dataclass
|
||||
class AdminGetRequestTraceAdapter(AdminApiAdapter):
|
||||
request_id: str
|
||||
attempted_only: bool = False
|
||||
|
||||
@staticmethod
|
||||
def _is_attempted_candidate(candidate: Any) -> bool:
|
||||
status = str(getattr(candidate, "status", "") or "").strip().lower()
|
||||
# pre-created / never executed rows should not be considered as attempted
|
||||
if status in {"", "available", "unused", "skipped"}:
|
||||
return False
|
||||
# pending must have started_at to be considered truly entered execution
|
||||
if status == "pending" and getattr(candidate, "started_at", None) is None:
|
||||
return False
|
||||
return True
|
||||
|
||||
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
||||
db = context.db
|
||||
|
||||
# 查询并展示该请求的全量候选:
|
||||
# - 包含 available/unused(未执行)
|
||||
# - 包含 skipped(跳过)
|
||||
# - 包含 pending/streaming/success/failed/cancelled(执行中/结果)
|
||||
all_candidates = RequestCandidateService.get_candidates_by_request_id(db, self.request_id)
|
||||
|
||||
# 如果没有数据,返回 404
|
||||
if not all_candidates:
|
||||
raise HTTPException(status_code=404, detail="Request not found")
|
||||
|
||||
candidates = (
|
||||
[c for c in all_candidates if self._is_attempted_candidate(c)]
|
||||
if self.attempted_only
|
||||
else all_candidates
|
||||
)
|
||||
|
||||
# 计算总延迟(只统计已完成的候选:success, failed, cancelled)
|
||||
# 使用显式的 is not None 检查,避免过滤掉 0ms 的快速响应
|
||||
total_latency = sum(
|
||||
c.latency_ms
|
||||
for c in candidates
|
||||
if c.status in ("success", "failed", "cancelled") and c.latency_ms is not None
|
||||
)
|
||||
|
||||
# 判断最终状态:
|
||||
# 1. status="success" 即视为成功(无论 status_code 是什么)
|
||||
# - 流式请求即使客户端断开(499),只要 Provider 成功返回数据,也算成功
|
||||
# 2. 同时检查 status_code 在 200-299 范围,作为额外的成功判断条件
|
||||
# - 用于兼容非流式请求或未正确设置 status 的旧数据
|
||||
# 3. status="streaming" 表示流式请求正在进行中
|
||||
# 4. status="pending" 表示请求尚未开始执行
|
||||
# 5. status="cancelled" 表示客户端主动断开连接(不算失败)
|
||||
final_status_source = all_candidates if self.attempted_only else candidates
|
||||
has_success = any(
|
||||
c.status == "success" or (c.status_code is not None and 200 <= c.status_code < 300)
|
||||
for c in final_status_source
|
||||
)
|
||||
has_streaming = any(c.status == "streaming" for c in final_status_source)
|
||||
has_pending = any(c.status == "pending" for c in final_status_source)
|
||||
has_cancelled = any(c.status == "cancelled" for c in final_status_source)
|
||||
has_failed = any(c.status == "failed" for c in final_status_source)
|
||||
|
||||
if has_success:
|
||||
final_status = "success"
|
||||
elif has_streaming:
|
||||
# 有候选正在流式传输中
|
||||
final_status = "streaming"
|
||||
elif has_pending:
|
||||
# 有候选正在等待执行
|
||||
final_status = "pending"
|
||||
elif has_cancelled and not has_failed:
|
||||
# 只有取消没有失败,算作取消
|
||||
final_status = "cancelled"
|
||||
else:
|
||||
final_status = "failed"
|
||||
|
||||
# 批量加载 provider 信息,避免 N+1 查询
|
||||
provider_ids = {c.provider_id for c in candidates if c.provider_id}
|
||||
provider_map: dict[str, str] = {}
|
||||
provider_website_map: dict[str, str | None] = {}
|
||||
provider_type_map: dict[str, str] = {}
|
||||
if provider_ids:
|
||||
providers = db.query(Provider).filter(Provider.id.in_(provider_ids)).all()
|
||||
for p in providers:
|
||||
provider_map[p.id] = p.name
|
||||
provider_website_map[p.id] = p.website
|
||||
provider_type_map[p.id] = getattr(p, "provider_type", "custom") or "custom"
|
||||
|
||||
# 批量加载 endpoint 信息
|
||||
endpoint_ids = {c.endpoint_id for c in candidates if c.endpoint_id}
|
||||
endpoint_map = {}
|
||||
if endpoint_ids:
|
||||
endpoints = (
|
||||
db.query(ProviderEndpoint).filter(ProviderEndpoint.id.in_(endpoint_ids)).all()
|
||||
)
|
||||
endpoint_map = {e.id: e.api_format for e in endpoints}
|
||||
|
||||
# 批量加载 key 信息
|
||||
key_ids = {c.key_id for c in candidates if c.key_id}
|
||||
key_map: dict[str, str] = {}
|
||||
key_preview_map: dict[str, str] = {}
|
||||
key_account_label_map: dict[str, str | None] = {}
|
||||
key_capabilities_map: dict[str, dict | None] = {}
|
||||
key_auth_type_map: dict[str, str] = {}
|
||||
key_oauth_plan_map: dict[str, str | None] = {}
|
||||
# 建立 key_id -> provider_id 的映射(用于获取 provider_type)
|
||||
key_provider_map: dict[str, str | None] = {
|
||||
c.key_id: c.provider_id for c in candidates if c.key_id
|
||||
}
|
||||
if key_ids:
|
||||
keys = db.query(ProviderAPIKey).filter(ProviderAPIKey.id.in_(key_ids)).all()
|
||||
for k in keys:
|
||||
key_map[k.id] = k.name
|
||||
key_account_label_map[k.id] = k.name
|
||||
key_capabilities_map[k.id] = k.capabilities
|
||||
|
||||
is_oauth = k.auth_type == "oauth"
|
||||
|
||||
if is_oauth:
|
||||
# OAuth: auth_type 使用具体的 provider_type(如 kiro/codex/antigravity)
|
||||
pid = key_provider_map.get(k.id)
|
||||
key_auth_type_map[k.id] = (
|
||||
provider_type_map.get(pid, "oauth") if pid else "oauth"
|
||||
)
|
||||
# 提取 plan_type(不同 provider 存储位置不同)
|
||||
oauth_plan_type = None
|
||||
# 1. Codex: auth_config.plan_type
|
||||
# 2. Antigravity: auth_config.tier
|
||||
if k.auth_config:
|
||||
try:
|
||||
decrypted_config = crypto_service.decrypt(k.auth_config)
|
||||
auth_config = json.loads(decrypted_config)
|
||||
email = auth_config.get("email")
|
||||
if isinstance(email, str) and email.strip():
|
||||
key_account_label_map[k.id] = email.strip()
|
||||
oauth_plan_type = auth_config.get("plan_type")
|
||||
if not oauth_plan_type:
|
||||
ag_tier = auth_config.get("tier")
|
||||
if ag_tier and isinstance(ag_tier, str):
|
||||
oauth_plan_type = ag_tier.lower()
|
||||
except Exception:
|
||||
pass
|
||||
# 3. Kiro: upstream_metadata.kiro.subscription_title
|
||||
# subscription_title 通常为 "KIRO FREE" / "KIRO PRO+" 等,
|
||||
# 去掉 provider 名称前缀,只保留等级部分
|
||||
if not oauth_plan_type:
|
||||
um = getattr(k, "upstream_metadata", None) or {}
|
||||
kiro_meta = um.get("kiro") if isinstance(um, dict) else None
|
||||
if isinstance(kiro_meta, dict):
|
||||
sub_title = kiro_meta.get("subscription_title")
|
||||
if sub_title and isinstance(sub_title, str):
|
||||
# "KIRO FREE" -> "Free", "KIRO PRO+" -> "Pro+"
|
||||
ptype = provider_type_map.get(pid, "") if pid else ""
|
||||
if ptype and sub_title.upper().startswith(ptype.upper()):
|
||||
sub_title = sub_title[len(ptype) :].strip()
|
||||
oauth_plan_type = sub_title
|
||||
key_oauth_plan_map[k.id] = oauth_plan_type
|
||||
continue
|
||||
else:
|
||||
key_auth_type_map[k.id] = k.auth_type or "api_key"
|
||||
|
||||
# 非 OAuth:生成脱敏预览
|
||||
try:
|
||||
decrypted_key = crypto_service.decrypt(k.api_key)
|
||||
if len(decrypted_key) > 8:
|
||||
# 检测常见前缀模式
|
||||
prefix_end = 0
|
||||
for prefix in ["sk-", "key-", "api-", "ak-"]:
|
||||
if decrypted_key.lower().startswith(prefix):
|
||||
prefix_end = len(prefix)
|
||||
break
|
||||
if prefix_end > 0:
|
||||
key_preview_map[k.id] = (
|
||||
f"{decrypted_key[:prefix_end]}***{decrypted_key[-4:]}"
|
||||
)
|
||||
else:
|
||||
key_preview_map[k.id] = f"{decrypted_key[:4]}***{decrypted_key[-4:]}"
|
||||
elif len(decrypted_key) > 4:
|
||||
key_preview_map[k.id] = f"***{decrypted_key[-4:]}"
|
||||
else:
|
||||
key_preview_map[k.id] = "***"
|
||||
except Exception:
|
||||
key_preview_map[k.id] = "***"
|
||||
|
||||
# 构建 candidate 响应列表
|
||||
candidate_responses: list[CandidateResponse] = []
|
||||
for candidate in candidates:
|
||||
provider_name = (
|
||||
provider_map.get(candidate.provider_id) if candidate.provider_id else None
|
||||
)
|
||||
provider_website = (
|
||||
provider_website_map.get(candidate.provider_id) if candidate.provider_id else None
|
||||
)
|
||||
endpoint_name = (
|
||||
endpoint_map.get(candidate.endpoint_id) if candidate.endpoint_id else None
|
||||
)
|
||||
key_name = key_map.get(candidate.key_id) if candidate.key_id else None
|
||||
key_account_label = (
|
||||
key_account_label_map.get(candidate.key_id) if candidate.key_id else None
|
||||
)
|
||||
key_preview = key_preview_map.get(candidate.key_id) if candidate.key_id else None
|
||||
key_auth_type = key_auth_type_map.get(candidate.key_id) if candidate.key_id else None
|
||||
key_oauth_plan_type = (
|
||||
key_oauth_plan_map.get(candidate.key_id) if candidate.key_id else None
|
||||
)
|
||||
key_capabilities = (
|
||||
key_capabilities_map.get(candidate.key_id) if candidate.key_id else None
|
||||
)
|
||||
|
||||
candidate_responses.append(
|
||||
CandidateResponse(
|
||||
id=candidate.id,
|
||||
request_id=candidate.request_id,
|
||||
candidate_index=candidate.candidate_index,
|
||||
retry_index=candidate.retry_index,
|
||||
provider_id=candidate.provider_id,
|
||||
provider_name=provider_name,
|
||||
provider_website=provider_website,
|
||||
endpoint_id=candidate.endpoint_id,
|
||||
endpoint_name=endpoint_name,
|
||||
key_id=candidate.key_id,
|
||||
key_name=key_name,
|
||||
key_account_label=key_account_label,
|
||||
key_preview=key_preview,
|
||||
key_auth_type=key_auth_type,
|
||||
key_oauth_plan_type=key_oauth_plan_type,
|
||||
key_capabilities=key_capabilities,
|
||||
required_capabilities=candidate.required_capabilities,
|
||||
status=candidate.status,
|
||||
skip_reason=candidate.skip_reason,
|
||||
is_cached=candidate.is_cached,
|
||||
status_code=candidate.status_code,
|
||||
error_type=candidate.error_type,
|
||||
error_message=candidate.error_message,
|
||||
latency_ms=candidate.latency_ms,
|
||||
concurrent_requests=candidate.concurrent_requests,
|
||||
extra_data=candidate.extra_data,
|
||||
created_at=candidate.created_at,
|
||||
started_at=candidate.started_at,
|
||||
finished_at=candidate.finished_at,
|
||||
)
|
||||
)
|
||||
|
||||
response = RequestTraceResponse(
|
||||
request_id=self.request_id,
|
||||
total_candidates=len(candidates),
|
||||
final_status=final_status,
|
||||
total_latency_ms=total_latency,
|
||||
candidates=candidate_responses,
|
||||
)
|
||||
context.add_audit_metadata(
|
||||
action="trace_request_detail",
|
||||
request_id=self.request_id,
|
||||
total_candidates=len(candidates),
|
||||
final_status=final_status,
|
||||
total_latency_ms=total_latency,
|
||||
attempted_only=self.attempted_only,
|
||||
)
|
||||
return response
|
||||
|
||||
|
||||
@dataclass
|
||||
class AdminProviderFailureRateAdapter(AdminApiAdapter):
|
||||
provider_id: str
|
||||
limit: int
|
||||
|
||||
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
||||
result = RequestCandidateService.get_candidate_stats_by_provider(
|
||||
db=context.db,
|
||||
provider_id=self.provider_id,
|
||||
limit=self.limit,
|
||||
)
|
||||
context.add_audit_metadata(
|
||||
action="trace_provider_failure_rate",
|
||||
provider_id=self.provider_id,
|
||||
limit=self.limit,
|
||||
total_attempts=result.get("total_attempts"),
|
||||
failure_rate=result.get("failure_rate"),
|
||||
)
|
||||
return result
|
||||
5
_deprecated_py_src/api/admin/payments/__init__.py
Normal file
5
_deprecated_py_src/api/admin/payments/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""Admin payment routes."""
|
||||
|
||||
from .routes import router
|
||||
|
||||
__all__ = ["router"]
|
||||
286
_deprecated_py_src/api/admin/payments/routes.py
Normal file
286
_deprecated_py_src/api/admin/payments/routes.py
Normal file
@@ -0,0 +1,286 @@
|
||||
"""管理员支付订单管理接口。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, Query, Request
|
||||
from fastapi.concurrency import run_in_threadpool
|
||||
from pydantic import BaseModel, Field, ValidationError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from src.api.base.admin_adapter import AdminApiAdapter
|
||||
from src.api.base.context import ApiRequestContext
|
||||
from src.api.base.pipeline import get_pipeline
|
||||
from src.api.serializers import serialize_payment_callback, serialize_payment_order
|
||||
from src.core.exceptions import InvalidRequestException, NotFoundException, translate_pydantic_error
|
||||
from src.database import get_db, get_db_context
|
||||
from src.services.payment import PaymentService
|
||||
|
||||
router = APIRouter(prefix="/api/admin/payments", tags=["Admin - Payments"])
|
||||
pipeline = get_pipeline()
|
||||
|
||||
|
||||
class AdminPaymentOrderCreditPayload(BaseModel):
|
||||
gateway_order_id: str | None = Field(default=None, max_length=128)
|
||||
pay_amount: float | None = Field(default=None, gt=0)
|
||||
pay_currency: str | None = Field(default=None, min_length=3, max_length=3)
|
||||
exchange_rate: float | None = Field(default=None, gt=0)
|
||||
gateway_response: dict[str, Any] | None = None
|
||||
|
||||
|
||||
def _parse_payload(model_cls: type[BaseModel], payload: dict[str, Any]) -> BaseModel:
|
||||
try:
|
||||
return model_cls.model_validate(payload)
|
||||
except ValidationError as exc:
|
||||
errors = exc.errors()
|
||||
if errors:
|
||||
raise InvalidRequestException(translate_pydantic_error(errors[0]))
|
||||
raise InvalidRequestException("请求数据验证失败")
|
||||
|
||||
|
||||
def _list_payment_orders_sync(
|
||||
status: str | None,
|
||||
payment_method: str | None,
|
||||
limit: int,
|
||||
offset: int,
|
||||
) -> dict[str, Any]:
|
||||
with get_db_context() as db:
|
||||
items, total, _changed = PaymentService.list_orders(
|
||||
db,
|
||||
status=status,
|
||||
payment_method=payment_method,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
return {
|
||||
"items": [serialize_payment_order(item) for item in items],
|
||||
"total": total,
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
}
|
||||
|
||||
|
||||
def _get_payment_order_sync(order_id: str) -> dict[str, Any]:
|
||||
with get_db_context() as db:
|
||||
order = PaymentService.get_order(db, order_id=order_id)
|
||||
if order is None:
|
||||
raise NotFoundException("Payment order not found")
|
||||
PaymentService.refresh_order_status(order)
|
||||
return {"order": serialize_payment_order(order)}
|
||||
|
||||
|
||||
def _expire_payment_order_sync(order_id: str) -> dict[str, Any]:
|
||||
with get_db_context() as db:
|
||||
order = PaymentService.get_order(db, order_id=order_id)
|
||||
if order is None:
|
||||
raise NotFoundException("Payment order not found")
|
||||
try:
|
||||
updated, expired = PaymentService.expire_order(
|
||||
db,
|
||||
order=order,
|
||||
reason="admin_mark_expired",
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise InvalidRequestException(str(exc)) from exc
|
||||
return {"order": serialize_payment_order(updated), "expired": expired}
|
||||
|
||||
|
||||
def _credit_payment_order_sync(
|
||||
order_id: str,
|
||||
payload: AdminPaymentOrderCreditPayload,
|
||||
operator_id: str | None,
|
||||
) -> dict[str, Any]:
|
||||
with get_db_context() as db:
|
||||
order = PaymentService.get_order(db, order_id=order_id)
|
||||
if order is None:
|
||||
raise NotFoundException("Payment order not found")
|
||||
|
||||
gateway_response = dict(order.gateway_response or {})
|
||||
if payload.gateway_response:
|
||||
gateway_response.update(payload.gateway_response)
|
||||
gateway_response["manual_credit"] = True
|
||||
gateway_response["credited_by"] = operator_id
|
||||
|
||||
try:
|
||||
updated, credited = PaymentService.credit_order(
|
||||
db,
|
||||
order=order,
|
||||
gateway_order_id=payload.gateway_order_id,
|
||||
gateway_response=gateway_response,
|
||||
pay_amount=payload.pay_amount,
|
||||
pay_currency=payload.pay_currency,
|
||||
exchange_rate=payload.exchange_rate,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise InvalidRequestException(str(exc)) from exc
|
||||
return {"order": serialize_payment_order(updated), "credited": credited}
|
||||
|
||||
|
||||
def _fail_payment_order_sync(order_id: str) -> dict[str, Any]:
|
||||
with get_db_context() as db:
|
||||
order = PaymentService.get_order(db, order_id=order_id)
|
||||
if order is None:
|
||||
raise NotFoundException("Payment order not found")
|
||||
try:
|
||||
updated = PaymentService.fail_order(
|
||||
db,
|
||||
order=order,
|
||||
reason="admin_mark_failed",
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise InvalidRequestException(str(exc)) from exc
|
||||
return {"order": serialize_payment_order(updated)}
|
||||
|
||||
|
||||
@router.get("/orders")
|
||||
async def list_payment_orders(
|
||||
request: Request,
|
||||
status: str | None = Query(None),
|
||||
payment_method: str | None = Query(None),
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
offset: int = Query(0, ge=0, le=5000),
|
||||
db: Session = Depends(get_db),
|
||||
) -> Any:
|
||||
adapter = AdminPaymentOrderListAdapter(
|
||||
status=status,
|
||||
payment_method=payment_method,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
|
||||
@router.get("/orders/{order_id}")
|
||||
async def get_payment_order(
|
||||
order_id: str,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
) -> Any:
|
||||
adapter = AdminPaymentOrderDetailAdapter(order_id=order_id)
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
|
||||
@router.post("/orders/{order_id}/expire")
|
||||
async def expire_payment_order(
|
||||
order_id: str,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
) -> Any:
|
||||
adapter = AdminPaymentOrderExpireAdapter(order_id=order_id)
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
|
||||
@router.post("/orders/{order_id}/credit")
|
||||
async def credit_payment_order(
|
||||
order_id: str,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
) -> Any:
|
||||
adapter = AdminPaymentOrderCreditAdapter(order_id=order_id)
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
|
||||
@router.post("/orders/{order_id}/fail")
|
||||
async def fail_payment_order(
|
||||
order_id: str,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
) -> Any:
|
||||
adapter = AdminPaymentOrderFailAdapter(order_id=order_id)
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
|
||||
@router.get("/callbacks")
|
||||
async def list_payment_callbacks(
|
||||
request: Request,
|
||||
payment_method: str | None = Query(None),
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
offset: int = Query(0, ge=0, le=5000),
|
||||
db: Session = Depends(get_db),
|
||||
) -> Any:
|
||||
adapter = AdminPaymentCallbackListAdapter(
|
||||
payment_method=payment_method,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
|
||||
@dataclass
|
||||
class AdminPaymentOrderListAdapter(AdminApiAdapter):
|
||||
status: str | None
|
||||
payment_method: str | None
|
||||
limit: int
|
||||
offset: int
|
||||
|
||||
async def handle(self, context: ApiRequestContext) -> dict[str, Any]:
|
||||
return await run_in_threadpool(
|
||||
_list_payment_orders_sync,
|
||||
self.status,
|
||||
self.payment_method,
|
||||
self.limit,
|
||||
self.offset,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class AdminPaymentOrderDetailAdapter(AdminApiAdapter):
|
||||
order_id: str
|
||||
|
||||
async def handle(self, context: ApiRequestContext) -> dict[str, Any]:
|
||||
return await run_in_threadpool(_get_payment_order_sync, self.order_id)
|
||||
|
||||
|
||||
@dataclass
|
||||
class AdminPaymentOrderExpireAdapter(AdminApiAdapter):
|
||||
order_id: str
|
||||
|
||||
async def handle(self, context: ApiRequestContext) -> dict[str, Any]:
|
||||
return await run_in_threadpool(_expire_payment_order_sync, self.order_id)
|
||||
|
||||
|
||||
@dataclass
|
||||
class AdminPaymentOrderCreditAdapter(AdminApiAdapter):
|
||||
order_id: str
|
||||
|
||||
async def handle(self, context: ApiRequestContext) -> dict[str, Any]:
|
||||
raw_payload = context.ensure_json_body() if context.raw_body else {}
|
||||
req = _parse_payload(AdminPaymentOrderCreditPayload, raw_payload)
|
||||
|
||||
return await run_in_threadpool(
|
||||
_credit_payment_order_sync,
|
||||
self.order_id,
|
||||
req,
|
||||
context.user.id if context.user else None,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class AdminPaymentOrderFailAdapter(AdminApiAdapter):
|
||||
order_id: str
|
||||
|
||||
async def handle(self, context: ApiRequestContext) -> dict[str, Any]:
|
||||
return await run_in_threadpool(_fail_payment_order_sync, self.order_id)
|
||||
|
||||
|
||||
@dataclass
|
||||
class AdminPaymentCallbackListAdapter(AdminApiAdapter):
|
||||
payment_method: str | None
|
||||
limit: int
|
||||
offset: int
|
||||
|
||||
async def handle(self, context: ApiRequestContext) -> dict[str, Any]:
|
||||
items, total = PaymentService.list_callbacks(
|
||||
context.db,
|
||||
payment_method=self.payment_method,
|
||||
limit=self.limit,
|
||||
offset=self.offset,
|
||||
)
|
||||
return {
|
||||
"items": [serialize_payment_callback(item) for item in items],
|
||||
"total": total,
|
||||
"limit": self.limit,
|
||||
"offset": self.offset,
|
||||
}
|
||||
5
_deprecated_py_src/api/admin/pool/__init__.py
Normal file
5
_deprecated_py_src/api/admin/pool/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""Pool management admin API."""
|
||||
|
||||
from .routes import router
|
||||
|
||||
__all__ = ["router"]
|
||||
1680
_deprecated_py_src/api/admin/pool/routes.py
Normal file
1680
_deprecated_py_src/api/admin/pool/routes.py
Normal file
File diff suppressed because it is too large
Load Diff
245
_deprecated_py_src/api/admin/pool/schemas.py
Normal file
245
_deprecated_py_src/api/admin/pool/schemas.py
Normal file
@@ -0,0 +1,245 @@
|
||||
"""Pydantic schemas for Pool management API."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from src.models.status_snapshot import ProviderKeyStatusSnapshotResponse
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Overview
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class PoolOverviewItem(BaseModel):
|
||||
"""One Provider in the overview list."""
|
||||
|
||||
provider_id: str
|
||||
provider_name: str
|
||||
provider_type: str = "custom"
|
||||
total_keys: int = 0
|
||||
active_keys: int = 0
|
||||
cooldown_count: int = 0
|
||||
pool_enabled: bool = False
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class PoolOverviewResponse(BaseModel):
|
||||
items: list[PoolOverviewItem] = Field(default_factory=list)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Scheduling presets metadata
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class PresetModeMetaResponse(BaseModel):
|
||||
value: str
|
||||
label: str
|
||||
|
||||
|
||||
class PresetDimensionMetaResponse(BaseModel):
|
||||
name: str
|
||||
label: str
|
||||
description: str
|
||||
providers: list[str] = Field(default_factory=list)
|
||||
modes: list[PresetModeMetaResponse] | None = None
|
||||
default_mode: str | None = None
|
||||
mutex_group: str | None = None
|
||||
evidence_hint: str | None = None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Paginated key list
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class PoolSchedulingReason(BaseModel):
|
||||
"""Structured scheduling reason for a key."""
|
||||
|
||||
code: str
|
||||
label: str
|
||||
blocking: bool = False
|
||||
source: str = "pool" # manual / pool / health / policy
|
||||
ttl_seconds: int | None = None
|
||||
detail: str | None = None
|
||||
|
||||
|
||||
class OAuthOrganizationSummary(BaseModel):
|
||||
id: str | None = None
|
||||
title: str | None = None
|
||||
is_default: bool = False
|
||||
role: str | None = None
|
||||
|
||||
|
||||
class PoolKeyDetail(BaseModel):
|
||||
"""Detailed status of a single pool key."""
|
||||
|
||||
key_id: str
|
||||
key_name: str
|
||||
is_active: bool
|
||||
auth_type: str = "api_key"
|
||||
oauth_expires_at: int | None = Field(
|
||||
default=None, description="兼容字段;优先使用 status_snapshot.oauth"
|
||||
)
|
||||
oauth_invalid_at: int | None = Field(
|
||||
default=None, description="兼容字段;优先使用 status_snapshot.oauth"
|
||||
)
|
||||
oauth_invalid_reason: str | None = Field(
|
||||
default=None, description="兼容字段;优先使用 status_snapshot.oauth"
|
||||
)
|
||||
oauth_plan_type: str | None = None
|
||||
oauth_account_id: str | None = None
|
||||
oauth_account_name: str | None = None
|
||||
oauth_account_user_id: str | None = None
|
||||
oauth_organizations: list[OAuthOrganizationSummary] = Field(default_factory=list)
|
||||
account_status_code: str | None = Field(
|
||||
default=None, description="兼容字段;优先使用 status_snapshot.account"
|
||||
)
|
||||
account_status_label: str | None = Field(
|
||||
default=None, description="兼容字段;优先使用 status_snapshot.account"
|
||||
)
|
||||
account_status_reason: str | None = Field(
|
||||
default=None, description="兼容字段;优先使用 status_snapshot.account"
|
||||
)
|
||||
account_status_blocked: bool = Field(
|
||||
default=False, description="兼容字段;优先使用 status_snapshot.account"
|
||||
)
|
||||
account_status_recoverable: bool = Field(
|
||||
default=False, description="兼容字段;优先使用 status_snapshot.account"
|
||||
)
|
||||
account_status_source: str | None = Field(
|
||||
default=None, description="兼容字段;优先使用 status_snapshot.account"
|
||||
)
|
||||
status_snapshot: ProviderKeyStatusSnapshotResponse = Field(
|
||||
default_factory=ProviderKeyStatusSnapshotResponse,
|
||||
description="统一的账号/OAuth/额度状态快照",
|
||||
)
|
||||
quota_updated_at: int | None = None
|
||||
# 健康度聚合字段(与 Provider Key 列表口径一致)
|
||||
health_score: float = 1.0
|
||||
circuit_breaker_open: bool = False
|
||||
# 编辑/权限/代理所需字段
|
||||
api_formats: list[str] = Field(default_factory=list)
|
||||
rate_multipliers: dict[str, float] | None = None
|
||||
internal_priority: int = 50
|
||||
rpm_limit: int | None = None
|
||||
cache_ttl_minutes: int = 5
|
||||
max_probe_interval_minutes: int = 32
|
||||
note: str | None = None
|
||||
allowed_models: list[str] | None = None
|
||||
capabilities: dict[str, bool] | None = None
|
||||
auto_fetch_models: bool = False
|
||||
locked_models: list[str] | None = None
|
||||
model_include_patterns: list[str] | None = None
|
||||
model_exclude_patterns: list[str] | None = None
|
||||
proxy: dict[str, Any] | None = None
|
||||
fingerprint: dict[str, Any] | None = None
|
||||
account_quota: str | None = None
|
||||
cooldown_reason: str | None = None
|
||||
cooldown_ttl_seconds: int | None = None
|
||||
cost_window_usage: int = 0
|
||||
cost_limit: int | None = None
|
||||
request_count: int = 0
|
||||
total_tokens: int = 0
|
||||
total_cost_usd: str = "0.00000000"
|
||||
sticky_sessions: int = 0
|
||||
lru_score: float | None = None
|
||||
created_at: str | None = None
|
||||
last_used_at: str | None = None
|
||||
scheduling_status: str = "available" # available / degraded / blocked
|
||||
scheduling_reason: str = "available"
|
||||
scheduling_label: str = "可用"
|
||||
scheduling_reasons: list[PoolSchedulingReason] = Field(default_factory=list)
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class PoolKeysPageResponse(BaseModel):
|
||||
"""Server-side paginated key list."""
|
||||
|
||||
total: int
|
||||
page: int
|
||||
page_size: int
|
||||
keys: list[PoolKeyDetail] = Field(default_factory=list)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Batch import
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class PoolKeyImportItem(BaseModel):
|
||||
"""Single key to import."""
|
||||
|
||||
name: str
|
||||
api_key: str
|
||||
auth_type: str = "api_key"
|
||||
|
||||
|
||||
class BatchImportRequest(BaseModel):
|
||||
keys: list[PoolKeyImportItem] = Field(..., max_length=500)
|
||||
proxy_node_id: str | None = Field(
|
||||
default=None,
|
||||
description="导入时绑定到账号的代理节点 ID(可选)",
|
||||
)
|
||||
|
||||
|
||||
class BatchImportError(BaseModel):
|
||||
index: int
|
||||
reason: str
|
||||
|
||||
|
||||
class BatchImportResponse(BaseModel):
|
||||
imported: int = 0
|
||||
skipped: int = 0
|
||||
errors: list[BatchImportError] = Field(default_factory=list)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Batch selection
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class PoolKeySelectionRequest(BaseModel):
|
||||
search: str = ""
|
||||
quick_selectors: list[str] = Field(default_factory=list, max_length=10)
|
||||
|
||||
|
||||
class PoolKeySelectionItem(BaseModel):
|
||||
key_id: str
|
||||
key_name: str = ""
|
||||
auth_type: str = "api_key"
|
||||
|
||||
|
||||
class PoolKeySelectionResponse(BaseModel):
|
||||
total: int = 0
|
||||
items: list[PoolKeySelectionItem] = Field(default_factory=list)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Batch action
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class BatchActionRequest(BaseModel):
|
||||
key_ids: list[str] = Field(..., max_length=2000)
|
||||
action: str # enable / disable / delete / clear_cooldown / reset_cost / regenerate_fingerprint / clear_proxy / set_proxy
|
||||
payload: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class BatchActionResponse(BaseModel):
|
||||
affected: int = 0
|
||||
message: str = ""
|
||||
task_id: str | None = None
|
||||
|
||||
|
||||
class BatchDeleteTaskResponse(BaseModel):
|
||||
task_id: str
|
||||
status: str # pending / running / completed / failed
|
||||
total: int = 0
|
||||
deleted: int = 0
|
||||
message: str = ""
|
||||
3492
_deprecated_py_src/api/admin/provider_oauth.py
Normal file
3492
_deprecated_py_src/api/admin/provider_oauth.py
Normal file
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user