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:
fawney19
2026-04-03 16:26:16 +08:00
parent 8f26e1a31f
commit 1d9c77522a
868 changed files with 1735 additions and 2433 deletions

View 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"]

View 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 计数已重置"}

View 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 的健康状态
查找所有处于熔断状态的 Keycircuit_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,
}

View 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,
)

View 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
),
}