Files
Aether/_deprecated_py_src/api/admin/providers/routes.py
fawney19 1d9c77522a 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)
2026-04-03 16:26:16 +08:00

1246 lines
47 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""管理员 Provider 管理路由。"""
from __future__ import annotations
import asyncio
import uuid
from datetime import datetime, timezone
from typing import Any
from fastapi import APIRouter, Depends, Query, Request
from pydantic import BaseModel, ConfigDict, 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.models_service import invalidate_models_list_cache
from src.api.base.pipeline import get_pipeline
from src.config.constants import CacheTTL
from src.core.enums import ProviderBillingType
from src.core.exceptions import InvalidRequestException, NotFoundException
from src.core.logger import logger
from src.core.model_permissions import match_model_with_pattern, parse_allowed_models_to_list
from src.core.provider_templates.fixed_providers import FIXED_PROVIDERS
from src.core.provider_templates.types import ProviderType
from src.database import get_db
from src.models.admin_requests import CreateProviderRequest, UpdateProviderRequest
from src.models.database import GlobalModel, Provider, ProviderAPIKey, ProviderEndpoint
from src.models.endpoint_models import ProviderWithEndpointsSummary
from src.services.cache.model_cache import ModelCacheService
from src.services.cache.provider_cache import ProviderCacheService
from src.services.provider.delete_task import get_provider_delete_task, submit_provider_delete
from src.utils.cache_decorator import cache_result
from .summary import _build_provider_summary
router = APIRouter(tags=["Provider CRUD"])
pipeline = get_pipeline()
# 映射预览配置(管理后台功能,限制宽松)
MAPPING_PREVIEW_MAX_KEYS = 200
MAPPING_PREVIEW_MAX_MODELS = 500
MAPPING_PREVIEW_TIMEOUT_SECONDS = 10.0
def _should_enable_format_conversion_by_default(provider_type: str | None) -> bool:
"""固定类型 Provider 默认是否开启格式转换。"""
pt = (provider_type or "custom").strip().lower()
envelope_provider_types = {
ProviderType.ANTIGRAVITY.value,
ProviderType.CLAUDE_CODE.value,
ProviderType.CODEX.value,
ProviderType.KIRO.value,
ProviderType.VERTEX_AI.value,
}
return pt in envelope_provider_types
def _normalize_provider_type(provider_type: str | None) -> str:
return (provider_type or "custom").strip().lower()
def _get_fixed_provider_template(provider_type: str | None) -> Any | None:
"""Return fixed-provider template when provider_type is managed by FIXED_PROVIDERS."""
normalized = _normalize_provider_type(provider_type)
try:
return FIXED_PROVIDERS.get(ProviderType(normalized))
except Exception:
return None
def _resolve_new_provider_priority(
current_min_priority: int | None, requested_priority: int | None
) -> tuple[int, bool]:
"""Resolve insertion priority for a newly created provider.
Returns ``(priority, needs_shift)``. When the caller explicitly specifies
a priority we need to shift existing rows. For auto-top insertion we prefer
``min - 1`` when that still stays non-negative; otherwise we clamp to ``0``
and shift existing rows down to preserve ordering.
"""
if requested_priority is not None:
return int(requested_priority), True
if current_min_priority is not None:
current_min = int(current_min_priority)
if current_min <= 0:
return 0, True
return current_min - 1, False
return 100, False
def _merge_pool_advanced_config(
*,
provider_config: dict[str, Any] | None,
pool_advanced: dict[str, Any] | None,
pool_advanced_in_payload: bool,
) -> tuple[dict[str, Any] | None, bool]:
"""合并 pool_advanced 到 provider.config任何 provider_type 均可使用)。"""
merged_config = dict(provider_config or {})
config_changed = False
if not pool_advanced_in_payload:
return merged_config or None, config_changed
if pool_advanced is None:
if "pool_advanced" in merged_config:
merged_config.pop("pool_advanced", None)
config_changed = True
else:
next_value = dict(pool_advanced)
if merged_config.get("pool_advanced") != next_value:
merged_config["pool_advanced"] = next_value
config_changed = True
return merged_config or None, config_changed
def _merge_failover_rules_config(
*,
provider_config: dict[str, Any] | None,
failover_rules: dict[str, Any] | None,
failover_rules_in_payload: bool,
) -> tuple[dict[str, Any] | None, bool]:
"""合并 failover_rules 到 provider.config。"""
merged_config = dict(provider_config or {})
config_changed = False
if not failover_rules_in_payload:
return merged_config or None, config_changed
if failover_rules is None:
if "failover_rules" in merged_config:
merged_config.pop("failover_rules", None)
config_changed = True
else:
next_value = dict(failover_rules)
if merged_config.get("failover_rules") != next_value:
merged_config["failover_rules"] = next_value
config_changed = True
return merged_config or None, config_changed
def _merge_claude_code_advanced_config(
*,
provider_type: str | None,
provider_config: dict[str, Any] | None,
claude_code_advanced: dict[str, Any] | None,
claude_advanced_in_payload: bool,
) -> tuple[dict[str, Any] | None, bool]:
"""合并并规范 claude_code_advanced确保仅在 claude_code 下保留。"""
normalized_provider_type = _normalize_provider_type(provider_type)
merged_config = dict(provider_config or {})
config_changed = False
if normalized_provider_type != ProviderType.CLAUDE_CODE.value:
if claude_advanced_in_payload and claude_code_advanced is not None:
raise InvalidRequestException("claude_code_advanced 仅适用于 provider_type=claude_code")
if "claude_code_advanced" in merged_config:
merged_config.pop("claude_code_advanced", None)
config_changed = True
return merged_config or None, config_changed
if not claude_advanced_in_payload:
return merged_config or None, config_changed
if claude_code_advanced is None:
if "claude_code_advanced" in merged_config:
merged_config.pop("claude_code_advanced", None)
config_changed = True
else:
next_value = dict(claude_code_advanced)
if merged_config.get("claude_code_advanced") != next_value:
merged_config["claude_code_advanced"] = next_value
config_changed = True
return merged_config or None, config_changed
# ========== Response Models ==========
class MappingMatchedModel(BaseModel):
"""匹配到的模型名称"""
allowed_model: str = Field(..., description="Key 白名单中匹配到的模型名")
mapping_pattern: str = Field(..., description="匹配的映射规则")
class MappingMatchingGlobalModel(BaseModel):
"""有映射匹配的 GlobalModel"""
global_model_id: str
global_model_name: str
display_name: str
is_active: bool
matched_models: list[MappingMatchedModel] = Field(
default_factory=list, description="匹配到的模型列表"
)
model_config = ConfigDict(from_attributes=True)
class MappingMatchingKey(BaseModel):
"""有映射匹配的 Key"""
key_id: str
key_name: str
masked_key: str
is_active: bool
allowed_models: list[str] = Field(default_factory=list, description="Key 的模型白名单")
matching_global_models: list[MappingMatchingGlobalModel] = Field(
default_factory=list, description="匹配到的 GlobalModel 列表"
)
model_config = ConfigDict(from_attributes=True)
class ProviderMappingPreviewResponse(BaseModel):
"""Provider 映射预览响应"""
provider_id: str
provider_name: str
keys: list[MappingMatchingKey] = Field(
default_factory=list, description="有白名单配置且匹配到映射的 Key 列表"
)
total_keys: int = Field(0, description="有匹配结果的 Key 数量")
total_matches: int = Field(
0, description="匹配到的 GlobalModel 数量(同一 GlobalModel 被多个 Key 匹配会重复计数)"
)
# 截断提示字段
truncated: bool = Field(False, description="是否因限制而截断结果")
truncated_keys: int = Field(0, description="被截断的 Key 数量")
truncated_models: int = Field(0, description="被截断的 GlobalModel 数量")
model_config = ConfigDict(from_attributes=True)
class ProviderDeleteSubmitResponse(BaseModel):
task_id: str
status: str = "pending"
message: str = ""
class ProviderDeleteTaskResponse(BaseModel):
task_id: str
provider_id: str
status: str
stage: str = "queued"
total_keys: int = 0
deleted_keys: int = 0
total_endpoints: int = 0
deleted_endpoints: int = 0
message: str = ""
@router.get("/")
async def list_providers(
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:
"""
获取提供商列表
获取所有提供商的基本信息列表,支持分页和状态过滤。
**查询参数**:
- `skip`: 跳过的记录数,用于分页,默认为 0
- `limit`: 返回的最大记录数,范围 1-500默认为 100
- `is_active`: 可选的活跃状态过滤true 仅返回活跃提供商false 返回禁用提供商,不传则返回全部
**返回字段**:
- `id`: 提供商 ID
- `name`: 提供商名称(唯一)
- `api_format`: API 格式(如 claude、openai、gemini 等)
- `base_url`: API 基础 URL
- `api_key`: API 密钥(脱敏显示)
- `priority`: 优先级
- `is_active`: 是否活跃
- `created_at`: 创建时间
- `updated_at`: 更新时间
"""
adapter = AdminListProvidersAdapter(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_provider(request: Request, db: Session = Depends(get_db)) -> Any:
"""
创建新提供商
创建一个新的 AI 模型提供商配置。
**请求体字段**:
- `name`: 提供商名称(必填,唯一)
- `description`: 描述信息(可选)
- `website`: 官网地址(可选)
- `billing_type`: 计费类型可选pay_as_you_go/subscription/prepaid默认 pay_as_you_go
- `monthly_quota_usd`: 月度配额(美元)(可选)
- `quota_reset_day`: 配额重置日期1-31可选
- `quota_last_reset_at`: 上次配额重置时间(可选)
- `quota_expires_at`: 配额过期时间(可选)
- `provider_priority`: 提供商优先级(数字越小优先级越高;不传时自动置顶,并将原有提供商顺延一位)
- `is_active`: 是否启用(默认 true
- `concurrent_limit`: 并发限制(可选)
- `max_retries`: 最大重试次数(可选)
- `proxy`: 代理配置(可选)
- `config`: 额外配置信息JSON可选
**返回字段**:
- `id`: 新创建的提供商 ID
- `name`: 提供商名称
- `message`: 成功提示信息
"""
adapter = AdminCreateProviderAdapter()
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@router.patch("/{provider_id}")
async def update_provider(
provider_id: str, request: Request, db: Session = Depends(get_db)
) -> ProviderWithEndpointsSummary:
"""
更新提供商配置
更新指定提供商的配置信息。只需传入需要更新的字段,未传入的字段保持不变。
**路径参数**:
- `provider_id`: 提供商 ID
**请求体字段**(所有字段可选):
- `name`: 提供商名称
- `description`: 描述信息
- `website`: 官网地址
- `billing_type`: 计费类型pay_as_you_go/subscription/prepaid
- `monthly_quota_usd`: 月度配额(美元)
- `quota_reset_day`: 配额重置日期1-31
- `quota_last_reset_at`: 上次配额重置时间
- `quota_expires_at`: 配额过期时间
- `provider_priority`: 提供商优先级
- `is_active`: 是否启用
- `concurrent_limit`: 并发限制
- `max_retries`: 最大重试次数
- `proxy`: 代理配置
- `config`: 额外配置信息JSON
**返回字段**:
- `id`: 提供商 ID
- `name`: 提供商名称
- `is_active`: 是否启用
- `message`: 成功提示信息
"""
adapter = AdminUpdateProviderAdapter(provider_id=provider_id)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@router.delete("/{provider_id}", response_model=ProviderDeleteSubmitResponse)
async def delete_provider(
provider_id: str, request: Request, db: Session = Depends(get_db)
) -> ProviderDeleteSubmitResponse:
"""
删除提供商
删除指定的提供商。注意:此操作会级联删除关联的端点、密钥和模型配置。
**路径参数**:
- `provider_id`: 提供商 ID
**返回字段**:
- `task_id`: 后台删除任务 ID
- `status`: 任务状态
- `message`: 提交结果提示
"""
adapter = AdminDeleteProviderAdapter(provider_id=provider_id)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@router.get("/{provider_id}/delete-task/{task_id}", response_model=ProviderDeleteTaskResponse)
async def get_delete_provider_task_status(
provider_id: str,
task_id: str,
request: Request,
db: Session = Depends(get_db),
) -> ProviderDeleteTaskResponse:
"""查询 Provider 删除任务状态。"""
adapter = AdminProviderDeleteTaskStatusAdapter(provider_id=provider_id, task_id=task_id)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
class AdminListProvidersAdapter(AdminApiAdapter):
def __init__(self, skip: int, limit: int, is_active: bool | None):
self.skip = skip
self.limit = limit
self.is_active = is_active
@cache_result(
key_prefix="admin:providers:list",
ttl=CacheTTL.ADMIN_USAGE_RECORDS,
user_specific=False,
vary_by=["skip", "limit", "is_active"],
)
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
db = context.db
query = db.query(Provider).options(
load_only(
Provider.id,
Provider.name,
Provider.provider_priority,
Provider.is_active,
Provider.created_at,
Provider.updated_at,
)
)
if self.is_active is not None:
query = query.filter(Provider.is_active == self.is_active)
providers = query.offset(self.skip).limit(self.limit).all()
data = []
for provider in providers:
api_format = getattr(provider, "api_format", None)
base_url = getattr(provider, "base_url", None)
api_key = getattr(provider, "api_key", None)
priority = getattr(provider, "priority", provider.provider_priority)
data.append(
{
"id": provider.id,
"name": provider.name,
"api_format": api_format.value if api_format else None,
"base_url": base_url,
"api_key": "***" if api_key else None,
"priority": priority,
"is_active": provider.is_active,
"created_at": provider.created_at.isoformat(),
"updated_at": provider.updated_at.isoformat() if provider.updated_at else None,
}
)
context.add_audit_metadata(
action="list_providers",
filter_is_active=self.is_active,
limit=self.limit,
skip=self.skip,
result_count=len(data),
)
return data
class AdminCreateProviderAdapter(AdminApiAdapter):
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
db = context.db
payload = context.ensure_json_body()
try:
# 使用 Pydantic 模型进行验证(自动进行 SQL 注入、XSS、SSRF 检测)
validated_data = CreateProviderRequest.model_validate(payload)
except ValidationError as exc:
# 将 Pydantic 验证错误转换为友好的错误信息
errors = []
for error in exc.errors():
field = " -> ".join(str(x) for x in error["loc"])
errors.append(f"{field}: {error['msg']}")
raise InvalidRequestException("输入验证失败: " + "; ".join(errors))
try:
# 检查名称是否已存在
existing = db.query(Provider).filter(Provider.name == validated_data.name).first()
if existing:
raise InvalidRequestException(f"提供商名称 '{validated_data.name}' 已存在")
# 将验证后的数据转换为枚举类型
billing_type = (
ProviderBillingType(validated_data.billing_type)
if validated_data.billing_type
else ProviderBillingType.PAY_AS_YOU_GO
)
# 有 envelope 包装的 Provider 类型(如 ClaudeCode、Antigravity、Codex需要
# 格式转换来正确解包上游响应,创建时默认开启 enable_format_conversion。
pt = _normalize_provider_type(validated_data.provider_type)
default_enable_format_conversion = _should_enable_format_conversion_by_default(pt)
provider_config, _ = _merge_claude_code_advanced_config(
provider_type=pt,
provider_config=validated_data.config,
claude_code_advanced=(
validated_data.claude_code_advanced.model_dump(exclude_none=True)
if validated_data.claude_code_advanced is not None
else None
),
claude_advanced_in_payload=validated_data.claude_code_advanced is not None,
)
provider_config, _pool_changed = _merge_pool_advanced_config(
provider_config=provider_config,
pool_advanced=(
validated_data.pool_advanced.model_dump(exclude_none=True)
if validated_data.pool_advanced is not None
else None
),
pool_advanced_in_payload=validated_data.pool_advanced is not None,
)
provider_config, _ = _merge_failover_rules_config(
provider_config=provider_config,
failover_rules=(
validated_data.failover_rules.model_dump()
if validated_data.failover_rules is not None
else None
),
failover_rules_in_payload=validated_data.failover_rules is not None,
)
current_min_priority = db.query(func.min(Provider.provider_priority)).scalar()
target_priority, needs_shift = _resolve_new_provider_priority(
current_min_priority=current_min_priority,
requested_priority=validated_data.provider_priority,
)
if needs_shift:
db.query(Provider).filter(
Provider.provider_priority.isnot(None),
Provider.provider_priority >= target_priority,
).update(
{Provider.provider_priority: Provider.provider_priority + 1},
synchronize_session=False,
)
# 创建 Provider 对象
provider = Provider(
name=validated_data.name,
provider_type=pt,
description=validated_data.description,
website=validated_data.website,
billing_type=billing_type,
monthly_quota_usd=validated_data.monthly_quota_usd,
quota_reset_day=validated_data.quota_reset_day,
quota_last_reset_at=validated_data.quota_last_reset_at,
quota_expires_at=validated_data.quota_expires_at,
provider_priority=target_priority,
keep_priority_on_conversion=validated_data.keep_priority_on_conversion,
is_active=validated_data.is_active,
concurrent_limit=validated_data.concurrent_limit,
max_retries=validated_data.max_retries,
proxy=validated_data.proxy.model_dump() if validated_data.proxy else None,
# 超时配置
stream_first_byte_timeout=validated_data.stream_first_byte_timeout,
request_timeout=validated_data.request_timeout,
config=provider_config or None,
# 有 envelope 的反代类型默认开启格式转换
enable_format_conversion=default_enable_format_conversion,
)
db.add(provider)
db.flush() # flush 获取 ID但不提交保持在同一事务中
# 固定类型 Provider自动创建并锁定预置 Endpoints同一事务
template = _get_fixed_provider_template(provider.provider_type)
if template:
from src.core.api_format.metadata import get_default_body_rules_for_endpoint
now = datetime.now(timezone.utc)
for sig in template.endpoint_signatures:
endpoint_config: dict[str, str] | None = None
if provider.provider_type == ProviderType.CODEX.value and sig == "openai:cli":
endpoint_config = {"upstream_stream_policy": "force_stream"}
# 获取 provider-scoped 默认 body rules
default_body_rules = (
get_default_body_rules_for_endpoint(
sig, provider_type=provider.provider_type
)
or None
)
endpoint = ProviderEndpoint(
id=str(uuid.uuid4()),
provider_id=provider.id,
api_format=sig,
api_family=sig.split(":", 1)[0],
endpoint_kind=sig.split(":", 1)[1],
base_url=template.api_base_url,
custom_path=None,
header_rules=None,
body_rules=default_body_rules,
max_retries=provider.max_retries or 2,
is_active=True,
config=endpoint_config,
proxy=None,
format_acceptance_config=None,
created_at=now,
updated_at=now,
)
db.add(endpoint)
db.commit()
db.refresh(provider)
# 清除 /v1/models 列表缓存
await invalidate_models_list_cache()
context.add_audit_metadata(
action="create_provider",
provider_id=provider.id,
provider_name=provider.name,
billing_type=provider.billing_type.value if provider.billing_type else None,
is_active=provider.is_active,
provider_priority=provider.provider_priority,
)
return {
"id": provider.id,
"name": provider.name,
"message": "提供商创建成功",
}
except InvalidRequestException:
db.rollback()
raise
except Exception:
db.rollback()
raise
class AdminUpdateProviderAdapter(AdminApiAdapter):
def __init__(self, provider_id: str):
self.provider_id = provider_id
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
db = context.db
payload = context.ensure_json_body()
# 查找 Provider
provider = db.query(Provider).filter(Provider.id == self.provider_id).first()
if not provider:
raise NotFoundException("提供商不存在", "provider")
try:
# 使用 Pydantic 模型进行验证(自动进行 SQL 注入、XSS、SSRF 检测)
validated_data = UpdateProviderRequest.model_validate(payload)
except ValidationError as exc:
# 将 Pydantic 验证错误转换为友好的错误信息
errors = []
for error in exc.errors():
field = " -> ".join(str(x) for x in error["loc"])
errors.append(f"{field}: {error['msg']}")
raise InvalidRequestException("输入验证失败: " + "; ".join(errors))
try:
# 更新字段(只更新非 None 的字段)
update_data = validated_data.model_dump(exclude_unset=True)
config_in_payload = "config" in update_data
claude_advanced_in_payload = "claude_code_advanced" in update_data
pool_advanced_in_payload = "pool_advanced" in update_data
failover_rules_in_payload = "failover_rules" in update_data
provider_config = (
dict(update_data.pop("config") or {})
if config_in_payload
else dict(provider.config or {})
)
claude_advanced = (
update_data.pop("claude_code_advanced") if claude_advanced_in_payload else None
)
pool_advanced = update_data.pop("pool_advanced") if pool_advanced_in_payload else None
failover_rules = (
update_data.pop("failover_rules") if failover_rules_in_payload else None
)
target_provider_type = (
update_data.get("provider_type")
or getattr(provider, "provider_type", None)
or "custom"
)
provider_config, config_changed_by_claude = _merge_claude_code_advanced_config(
provider_type=target_provider_type,
provider_config=provider_config,
claude_code_advanced=claude_advanced,
claude_advanced_in_payload=claude_advanced_in_payload,
)
provider_config, config_changed_by_pool = _merge_pool_advanced_config(
provider_config=provider_config,
pool_advanced=pool_advanced,
pool_advanced_in_payload=pool_advanced_in_payload,
)
provider_config, config_changed_by_failover = _merge_failover_rules_config(
provider_config=provider_config,
failover_rules=failover_rules,
failover_rules_in_payload=failover_rules_in_payload,
)
config_touched = (
config_in_payload
or claude_advanced_in_payload
or config_changed_by_claude
or pool_advanced_in_payload
or config_changed_by_pool
or failover_rules_in_payload
or config_changed_by_failover
)
if config_touched:
update_data["config"] = provider_config
for field, value in update_data.items():
if field == "billing_type" and value is not None:
# billing_type 需要转换为枚举
setattr(provider, field, ProviderBillingType(value))
elif field == "provider_type" and value is not None:
setattr(provider, field, value)
elif field == "proxy" and value is not None:
# proxy 需要转换为 dict如果是 Pydantic 模型)
setattr(
provider, field, value if isinstance(value, dict) else value.model_dump()
)
else:
setattr(provider, field, value)
provider.updated_at = datetime.now(timezone.utc)
db.commit()
db.refresh(provider)
# 清除 /v1/models 列表缓存is_active 变更会影响模型可用性)
await invalidate_models_list_cache()
# 如果更新了 is_active清除 GlobalModel 解析缓存
# Provider 状态变更会影响模型解析结果
if "is_active" in update_data:
await ModelCacheService.invalidate_all_resolve_cache()
# 如果更新了 billing_type清除缓存
if "billing_type" in update_data:
await ProviderCacheService.invalidate_provider_cache(provider.id)
logger.debug(f"已清除 Provider 缓存: {provider.id}")
context.add_audit_metadata(
action="update_provider",
provider_id=provider.id,
changed_fields=list(update_data.keys()),
is_active=provider.is_active,
provider_priority=provider.provider_priority,
)
return _build_provider_summary(db, provider)
except InvalidRequestException:
db.rollback()
raise
except Exception:
db.rollback()
raise
class AdminDeleteProviderAdapter(AdminApiAdapter):
def __init__(self, provider_id: str):
self.provider_id = provider_id
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("提供商不存在", "provider")
context.add_audit_metadata(
action="delete_provider",
provider_id=provider.id,
provider_name=provider.name,
)
task_id = await submit_provider_delete(provider.id)
provider_was_active = bool(provider.is_active)
if provider_was_active:
provider.is_active = False
db.commit()
await invalidate_models_list_cache()
await ModelCacheService.invalidate_all_resolve_cache()
await ProviderCacheService.invalidate_provider_cache(provider.id)
context.add_audit_metadata(
task_id=task_id,
provider_deactivated=provider_was_active,
)
return {
"task_id": task_id,
"status": "pending",
"message": "删除任务已提交,提供商已进入后台删除队列",
}
class AdminProviderDeleteTaskStatusAdapter(AdminApiAdapter):
def __init__(self, provider_id: str, task_id: str):
self.provider_id = provider_id
self.task_id = task_id
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
from fastapi import HTTPException
task = await get_provider_delete_task(self.task_id)
if task is None or task.provider_id != self.provider_id:
raise HTTPException(status_code=404, detail="Task not found")
return ProviderDeleteTaskResponse(
task_id=task.task_id,
provider_id=task.provider_id,
status=task.status,
stage=task.stage,
total_keys=task.total_keys,
deleted_keys=task.deleted_keys,
total_endpoints=task.total_endpoints,
deleted_endpoints=task.deleted_endpoints,
message=task.message,
)
@router.get(
"/{provider_id}/mapping-preview",
response_model=ProviderMappingPreviewResponse,
)
async def get_provider_mapping_preview(
request: Request,
provider_id: str,
db: Session = Depends(get_db),
) -> ProviderMappingPreviewResponse:
"""
获取 Provider 映射预览
查看该 Provider 的 Key 白名单能够被哪些 GlobalModel 的映射规则匹配。
**路径参数**:
- `provider_id`: Provider ID
**返回字段**:
- `provider_id`: Provider ID
- `provider_name`: Provider 名称
- `keys`: 有白名单配置的 Key 列表,每个包含:
- `key_id`: Key ID
- `key_name`: Key 名称
- `masked_key`: 脱敏的 Key
- `allowed_models`: Key 的白名单模型列表
- `matching_global_models`: 匹配到的 GlobalModel 列表
- `total_keys`: 有白名单配置的 Key 总数
- `total_matches`: 匹配到的 GlobalModel 总数
"""
adapter = AdminGetProviderMappingPreviewAdapter(provider_id=provider_id)
# 添加超时保护,防止复杂匹配导致的 DoS
try:
return await asyncio.wait_for(
pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode),
timeout=MAPPING_PREVIEW_TIMEOUT_SECONDS,
)
except TimeoutError:
logger.warning(f"映射预览超时: provider_id={provider_id}")
raise InvalidRequestException("映射预览超时,请简化配置或稍后重试")
class AdminGetProviderMappingPreviewAdapter(AdminApiAdapter):
"""获取 Provider 映射预览"""
def __init__(self, provider_id: str):
self.provider_id = provider_id
@cache_result(
key_prefix="admin:providers:mapping-preview",
ttl=CacheTTL.ADMIN_USAGE_RECORDS,
user_specific=False,
vary_by=["provider_id"],
)
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
db = context.db
# 获取 Provider
provider = (
db.query(Provider)
.options(load_only(Provider.id, Provider.name))
.filter(Provider.id == self.provider_id)
.first()
)
if not provider:
raise NotFoundException("提供商不存在", "provider")
# 统计截断情况
truncated_keys = 0
truncated_models = 0
# 获取该 Provider 有白名单配置的 Key只查询需要的字段
keys = (
db.query(
ProviderAPIKey.id,
ProviderAPIKey.name,
ProviderAPIKey.api_key,
ProviderAPIKey.is_active,
ProviderAPIKey.allowed_models,
)
.filter(
ProviderAPIKey.provider_id == self.provider_id,
ProviderAPIKey.allowed_models.isnot(None),
)
.limit(MAPPING_PREVIEW_MAX_KEYS + 1)
.all()
)
if len(keys) > MAPPING_PREVIEW_MAX_KEYS:
keys = keys[:MAPPING_PREVIEW_MAX_KEYS]
total_keys_with_allowed_models = (
db.query(func.count(ProviderAPIKey.id))
.filter(
ProviderAPIKey.provider_id == self.provider_id,
ProviderAPIKey.allowed_models.isnot(None),
)
.scalar()
or 0
)
truncated_keys = total_keys_with_allowed_models - MAPPING_PREVIEW_MAX_KEYS
# 只查询有 model_mappings 配置的 GlobalModel使用 SQLAlchemy JSONB 操作符)
global_models = (
db.query(
GlobalModel.id,
GlobalModel.name,
GlobalModel.display_name,
GlobalModel.is_active,
GlobalModel.config,
)
.filter(
GlobalModel.config.isnot(None),
GlobalModel.config["model_mappings"].isnot(None),
func.jsonb_array_length(GlobalModel.config["model_mappings"]) > 0,
)
.limit(MAPPING_PREVIEW_MAX_MODELS + 1)
.all()
)
if len(global_models) > MAPPING_PREVIEW_MAX_MODELS:
global_models = global_models[:MAPPING_PREVIEW_MAX_MODELS]
total_models_with_mappings = (
db.query(func.count(GlobalModel.id))
.filter(
GlobalModel.config.isnot(None),
GlobalModel.config["model_mappings"].isnot(None),
func.jsonb_array_length(GlobalModel.config["model_mappings"]) > 0,
)
.scalar()
or 0
)
truncated_models = total_models_with_mappings - MAPPING_PREVIEW_MAX_MODELS
# 构建有映射配置的 GlobalModel 映射
models_with_mappings: dict[str, tuple] = {} # id -> (model_info, mappings)
for gm in global_models:
config = gm.config or {}
mappings = config.get("model_mappings", [])
if mappings:
models_with_mappings[gm.id] = (gm, mappings)
# 如果没有任何带映射的 GlobalModel直接返回空结果
if not models_with_mappings:
return ProviderMappingPreviewResponse(
provider_id=provider.id,
provider_name=provider.name,
keys=[],
total_keys=0,
total_matches=0,
truncated=truncated_keys > 0 or truncated_models > 0,
truncated_keys=truncated_keys,
truncated_models=truncated_models,
).model_dump()
key_infos: list[MappingMatchingKey] = []
total_matches = 0
# 创建 CryptoService 实例
from src.core.crypto import CryptoService
crypto = CryptoService()
for key in keys:
allowed_models_list = parse_allowed_models_to_list(key.allowed_models)
if not allowed_models_list:
continue
# 查找匹配的 GlobalModel
matching_global_models: list[MappingMatchingGlobalModel] = []
for gm_id, (gm, mappings) in models_with_mappings.items():
matched_models: list[MappingMatchedModel] = []
for allowed_model in allowed_models_list:
for mapping_pattern in mappings:
if match_model_with_pattern(mapping_pattern, allowed_model):
matched_models.append(
MappingMatchedModel(
allowed_model=allowed_model,
mapping_pattern=mapping_pattern,
)
)
break # 一个 allowed_model 只需匹配一个映射
if matched_models:
matching_global_models.append(
MappingMatchingGlobalModel(
global_model_id=gm.id,
global_model_name=gm.name,
display_name=gm.display_name,
is_active=bool(gm.is_active),
matched_models=matched_models,
)
)
total_matches += 1
if matching_global_models:
# 只有有匹配结果的 key 才做解密脱敏,减少 CPU 开销
masked_key = "***"
if key.api_key:
try:
decrypted_key = crypto.decrypt(key.api_key, silent=True)
if len(decrypted_key) > 8:
masked_key = f"{decrypted_key[:4]}***{decrypted_key[-4:]}"
else:
masked_key = f"{decrypted_key[:2]}***"
except Exception:
pass
key_infos.append(
MappingMatchingKey(
key_id=key.id or "",
key_name=key.name or "",
masked_key=masked_key,
is_active=bool(key.is_active),
allowed_models=allowed_models_list,
matching_global_models=matching_global_models,
)
)
is_truncated = truncated_keys > 0 or truncated_models > 0
return ProviderMappingPreviewResponse(
provider_id=provider.id,
provider_name=provider.name,
keys=key_infos,
total_keys=len(key_infos),
total_matches=total_matches,
truncated=is_truncated,
truncated_keys=truncated_keys,
truncated_models=truncated_models,
).model_dump()
# ========== Claude Code Pool Management ==========
class PoolKeyStatus(BaseModel):
"""Single key's pool status."""
key_id: str
key_name: str
is_active: bool
cooldown_reason: str | None = None
cooldown_ttl_seconds: int | None = None
cost_window_usage: int = 0
cost_limit: int | None = None
sticky_sessions: int = 0
lru_score: float | None = None
model_config = ConfigDict(from_attributes=True)
class PoolStatusResponse(BaseModel):
"""Pool status for a Provider with pool config."""
provider_id: str
provider_name: str
pool_enabled: bool = False
total_keys: int = 0
total_sticky_sessions: int = 0
keys: list[PoolKeyStatus] = Field(default_factory=list)
model_config = ConfigDict(from_attributes=True)
@router.get("/{provider_id}/pool-status", response_model=PoolStatusResponse)
async def get_pool_status(
request: Request,
provider_id: str,
db: Session = Depends(get_db),
) -> PoolStatusResponse:
"""获取 Provider 的号池状态。"""
adapter = AdminProviderPoolStatusAdapter(provider_id=provider_id)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
async def _get_pool_status_response(db: Session, provider_id: str) -> PoolStatusResponse:
"""获取 Provider 的号池状态。"""
provider = db.query(Provider).filter(Provider.id == provider_id).first()
if not provider:
raise NotFoundException("提供商不存在", "provider")
from src.services.provider.pool import redis_ops as pool_redis
from src.services.provider.pool.config import parse_pool_config
pcfg = parse_pool_config(provider.config)
if pcfg is None:
return PoolStatusResponse(
provider_id=provider.id,
provider_name=provider.name,
pool_enabled=False,
)
keys = db.query(ProviderAPIKey).filter(ProviderAPIKey.provider_id == provider_id).all()
key_ids = [str(k.id) for k in keys]
pid = str(provider.id)
import asyncio
# Batch fetch pool state (parallel)
lru_coro = (
pool_redis.get_lru_scores(pid, key_ids) if pcfg.lru_enabled else asyncio.sleep(0, result={})
)
cooldowns, cooldown_ttls, lru_scores, cost_totals, total_sticky = await asyncio.gather(
pool_redis.batch_get_cooldowns(pid, key_ids),
pool_redis.batch_get_cooldown_ttls(pid, key_ids),
lru_coro,
pool_redis.batch_get_cost_totals(pid, key_ids, pcfg.cost_window_seconds),
pool_redis.get_sticky_session_count(pid),
)
# Sticky count per key requires SCAN+MGET; batch with gather.
sticky_counts: dict[str, int] = {}
if key_ids:
counts = await asyncio.gather(
*(pool_redis.get_key_sticky_count(pid, kid) for kid in key_ids)
)
sticky_counts = dict(zip(key_ids, counts))
key_statuses: list[PoolKeyStatus] = []
for k in keys:
kid = str(k.id)
cd_reason = cooldowns.get(kid)
key_statuses.append(
PoolKeyStatus(
key_id=kid,
key_name=k.name or "",
is_active=bool(k.is_active),
cooldown_reason=cd_reason,
cooldown_ttl_seconds=cooldown_ttls.get(kid) if cd_reason else None,
cost_window_usage=cost_totals.get(kid, 0),
cost_limit=pcfg.cost_limit_per_key_tokens,
sticky_sessions=sticky_counts.get(kid, 0),
lru_score=lru_scores.get(kid),
)
)
return PoolStatusResponse(
provider_id=provider.id,
provider_name=provider.name,
pool_enabled=True,
total_keys=len(keys),
total_sticky_sessions=total_sticky,
keys=key_statuses,
)
@router.post("/{provider_id}/pool/clear-cooldown/{key_id}")
async def clear_pool_cooldown(
request: Request,
provider_id: str,
key_id: str,
db: Session = Depends(get_db),
) -> dict[str, str]:
"""手动清除指定 Key 的号池冷却状态。"""
adapter = AdminProviderClearPoolCooldownAdapter(provider_id=provider_id, key_id=key_id)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
async def _clear_pool_cooldown_response(db: Session, provider_id: str, key_id: str) -> dict[str, str]:
"""手动清除指定 Key 的号池冷却状态。"""
provider = db.query(Provider).filter(Provider.id == provider_id).first()
if not provider:
raise NotFoundException("提供商不存在", "provider")
key = (
db.query(ProviderAPIKey)
.filter(ProviderAPIKey.id == key_id, ProviderAPIKey.provider_id == provider_id)
.first()
)
if not key:
raise NotFoundException("密钥不存在", "key")
from src.services.provider.pool import redis_ops as pool_redis
await pool_redis.clear_cooldown(str(provider.id), str(key.id))
return {"message": f"已清除 Key {key.name or key_id} 的冷却状态"}
@router.post("/{provider_id}/pool/reset-cost/{key_id}")
async def reset_pool_cost(
request: Request,
provider_id: str,
key_id: str,
db: Session = Depends(get_db),
) -> dict[str, str]:
"""重置指定 Key 的号池成本窗口。"""
adapter = AdminProviderResetPoolCostAdapter(provider_id=provider_id, key_id=key_id)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
async def _reset_pool_cost_response(db: Session, provider_id: str, key_id: str) -> dict[str, str]:
"""重置指定 Key 的号池成本窗口。"""
provider = db.query(Provider).filter(Provider.id == provider_id).first()
if not provider:
raise NotFoundException("提供商不存在", "provider")
key = (
db.query(ProviderAPIKey)
.filter(ProviderAPIKey.id == key_id, ProviderAPIKey.provider_id == provider_id)
.first()
)
if not key:
raise NotFoundException("密钥不存在", "key")
from src.services.provider.pool import redis_ops as pool_redis
await pool_redis.clear_cost(str(provider.id), str(key.id))
return {"message": f"已重置 Key {key.name or key_id} 的成本窗口"}
class AdminProviderPoolStatusAdapter(AdminApiAdapter):
def __init__(self, provider_id: str):
self.provider_id = provider_id
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
return await _get_pool_status_response(context.db, self.provider_id)
class AdminProviderClearPoolCooldownAdapter(AdminApiAdapter):
def __init__(self, provider_id: str, key_id: str):
self.provider_id = provider_id
self.key_id = key_id
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
return await _clear_pool_cooldown_response(context.db, self.provider_id, self.key_id)
class AdminProviderResetPoolCostAdapter(AdminApiAdapter):
def __init__(self, provider_id: str, key_id: str):
self.provider_id = provider_id
self.key_id = key_id
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
return await _reset_pool_cost_response(context.db, self.provider_id, self.key_id)