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 @@
"""Public-facing API routers.
Keep the compatibility frontdoor surface explicit so Rust can take ownership of
that manifest later without re-auditing the entire Python public app shell.
"""
from __future__ import annotations
from fastapi import APIRouter
from .support import python_public_support_router
router = APIRouter()
router.include_router(python_public_support_router)
__all__ = ["frontdoor_compat_router", "python_public_support_router", "router"]
def __getattr__(name: str) -> object:
if name == "frontdoor_compat_router":
from .compat import frontdoor_compat_router
return frontdoor_compat_router
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")

View File

@@ -0,0 +1,193 @@
"""
能力配置公共 API
提供系统支持的能力列表,供前端展示和配置使用。
"""
from dataclasses import dataclass
from typing import Any
from fastapi import APIRouter, Depends, Request
from sqlalchemy.orm import Session
from src.api.base.adapter import ApiAdapter, ApiMode
from src.api.base.context import ApiRequestContext
from src.api.base.pipeline import get_pipeline
from src.core.key_capabilities import (
get_all_capabilities,
get_user_configurable_capabilities,
)
from src.database import get_db
router = APIRouter(prefix="/api/capabilities", tags=["System Catalog"])
pipeline = get_pipeline()
def _serialize_capability(cap: Any) -> dict[str, Any]:
return {
"name": cap.name,
"display_name": cap.display_name,
"short_name": cap.short_name,
"description": cap.description,
"match_mode": cap.match_mode.value,
"config_mode": cap.config_mode.value,
}
class PublicCapabilitiesApiAdapter(ApiAdapter):
mode = ApiMode.PUBLIC
def authorize(self, context: ApiRequestContext) -> None: # type: ignore[override]
return None
class PublicCapabilitiesListAdapter(PublicCapabilitiesApiAdapter):
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
del context
return {"capabilities": [_serialize_capability(cap) for cap in get_all_capabilities()]}
class PublicUserConfigurableCapabilitiesAdapter(PublicCapabilitiesApiAdapter):
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
del context
return {
"capabilities": [
_serialize_capability(cap) for cap in get_user_configurable_capabilities()
]
}
@dataclass
class PublicModelCapabilitiesAdapter(PublicCapabilitiesApiAdapter):
model_name: str
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
from src.models.database import GlobalModel
global_model = (
context.db.query(GlobalModel)
.filter(GlobalModel.name == self.model_name, GlobalModel.is_active == True)
.first()
)
if not global_model:
return {
"model": self.model_name,
"supported_capabilities": [],
"capability_details": [],
"error": "模型不存在",
}
supported_caps = global_model.supported_capabilities or []
all_caps = {cap.name: cap for cap in get_all_capabilities()}
capability_details = [
{
"name": cap.name,
"display_name": cap.display_name,
"description": cap.description,
"match_mode": cap.match_mode.value,
"config_mode": cap.config_mode.value,
}
for cap_name in supported_caps
if (cap := all_caps.get(cap_name)) is not None
]
return {
"model": self.model_name,
"global_model_id": str(global_model.id),
"global_model_name": global_model.name,
"supported_capabilities": supported_caps,
"capability_details": capability_details,
}
@router.get("")
async def list_capabilities(request: Request, db: Session = Depends(get_db)) -> Any:
"""
获取所有能力定义
返回系统中定义的所有能力capabilities包括用户可配置和系统内部使用的能力。
能力用于描述模型支持的功能特性,如视觉输入、函数调用、流式输出等。
**返回字段**
- capabilities: 能力列表,每个能力包含:
- name: 能力的唯一标识符(如 vision、function_calling
- display_name: 能力的显示名称(如"视觉输入""函数调用"
- short_name: 能力的简短名称(如"视觉""函数"
- description: 能力的详细描述
- match_mode: 匹配模式exact 精确匹配fuzzy 模糊匹配prefix 前缀匹配等)
- config_mode: 配置模式user_configurable 用户可配置system_only 仅系统使用)
"""
adapter = PublicCapabilitiesListAdapter()
return await pipeline.run(
adapter=adapter,
http_request=request,
db=db,
mode=ApiMode.PUBLIC,
)
@router.get("/user-configurable")
async def list_user_configurable_capabilities(
request: Request,
db: Session = Depends(get_db),
) -> Any:
"""
获取用户可配置的能力列表
返回允许用户在 API Key 中配置的能力列表,用于前端展示配置选项。
用户可以通过配置这些能力来限制或指定 API Key 可以访问的模型功能。
**返回字段**
- capabilities: 用户可配置的能力列表,每个能力包含:
- name: 能力的唯一标识符
- display_name: 能力的显示名称
- short_name: 能力的简短名称
- description: 能力的详细描述
- match_mode: 匹配模式exact、fuzzy、prefix 等)
- config_mode: 配置模式(此接口返回的都是 user_configurable
"""
adapter = PublicUserConfigurableCapabilitiesAdapter()
return await pipeline.run(
adapter=adapter,
http_request=request,
db=db,
mode=ApiMode.PUBLIC,
)
@router.get("/model/{model_name}")
async def get_model_supported_capabilities(
model_name: str,
request: Request,
db: Session = Depends(get_db),
) -> Any:
"""
获取指定模型支持的能力列表
根据全局模型名称GlobalModel.name查询该模型支持的能力
并返回每个能力的详细定义。只查询活跃的全局模型。
**路径参数**
- model_name: 全局模型名称(如 claude-sonnet-4-20250514必须是 GlobalModel.name
**返回字段**
- model: 查询的模型名称
- global_model_id: 全局模型的 UUID
- global_model_name: 全局模型的标准名称
- supported_capabilities: 该模型支持的能力名称列表
- capability_details: 支持的能力详细信息列表,每个能力包含:
- name: 能力标识符
- display_name: 能力显示名称
- description: 能力描述
- match_mode: 匹配模式
- config_mode: 配置模式
- error: 错误信息(仅在模型不存在时返回)
"""
adapter = PublicModelCapabilitiesAdapter(model_name=model_name)
return await pipeline.run(
adapter=adapter,
http_request=request,
db=db,
mode=ApiMode.PUBLIC,
)

View File

@@ -0,0 +1,850 @@
"""
公开API端点 - 用户可查看的提供商和模型信息
不包含敏感信息,普通用户可访问
"""
from __future__ import annotations
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 and_, func, or_
from sqlalchemy.orm import Session, joinedload, load_only
from src.api.base.adapter import ApiAdapter, ApiMode
from src.api.base.context import ApiRequestContext
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.api import (
ProviderStatsResponse,
PublicGlobalModelListResponse,
PublicGlobalModelResponse,
PublicModelResponse,
PublicProviderResponse,
)
from src.models.database import (
GlobalModel,
Model,
Provider,
ProviderEndpoint,
RequestCandidate,
)
from src.models.endpoint_models import (
PublicApiFormatHealthMonitor,
PublicApiFormatHealthMonitorResponse,
PublicHealthEvent,
)
from src.services.health.endpoint import EndpointHealthService
from src.services.system.config import SystemConfigService
from src.utils.cache_decorator import cache_result
router = APIRouter(prefix="/api/public", tags=["System Catalog"])
python_host_router = APIRouter(prefix="/api/public", tags=["System Catalog"])
pipeline = get_pipeline()
def _fetch_recent_public_health_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()
)
@router.get("/site-info")
async def get_site_info(request: Request, db: Session = Depends(get_db)) -> Any:
"""获取站点基本信息(公开接口,无需认证)"""
adapter = PublicSiteInfoAdapter()
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=ApiMode.PUBLIC)
@router.get("/providers", response_model=list[PublicProviderResponse])
async def get_public_providers(
request: Request,
is_active: bool | None = Query(None, description="过滤活跃状态"),
skip: int = Query(0, description="跳过记录数"),
limit: int = Query(100, description="返回记录数限制"),
db: Session = Depends(get_db),
) -> Any:
"""
获取提供商列表(用户视图)
返回系统中可用的提供商列表,包含提供商的基本信息和统计数据。
默认只返回活跃的提供商。
**查询参数**
- is_active: 可选过滤活跃状态。None 表示只返回活跃提供商True 返回活跃False 返回非活跃
- skip: 跳过的记录数,用于分页,默认 0
- limit: 返回记录数限制,默认 100最大 100
**返回字段**
- id: 提供商唯一标识符
- name: 提供商名称(英文标识)
- display_name: 提供商显示名称
- description: 提供商描述信息
- is_active: 是否活跃
- provider_priority: 提供商优先级
- models_count: 该提供商下的模型总数
- active_models_count: 该提供商下活跃的模型数
- endpoints_count: 该提供商下的端点总数
- active_endpoints_count: 该提供商下活跃的端点数
"""
adapter = PublicProvidersAdapter(is_active=is_active, skip=skip, limit=limit)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=ApiMode.PUBLIC)
@router.get("/models", response_model=list[PublicModelResponse])
async def get_public_models(
request: Request,
provider_id: str | None = Query(None, description="提供商ID过滤"),
is_active: bool | None = Query(None, description="过滤活跃状态"),
skip: int = Query(0, description="跳过记录数"),
limit: int = Query(100, description="返回记录数限制"),
db: Session = Depends(get_db),
) -> Any:
"""
获取模型列表(用户视图)
返回系统中可用的模型列表,包含模型的详细信息和定价。
默认只返回活跃提供商下的活跃模型。
**查询参数**
- provider_id: 可选,按提供商 ID 过滤,只返回该提供商下的模型
- is_active: 可选,过滤活跃状态(当前未使用,始终返回活跃模型)
- skip: 跳过的记录数,用于分页,默认 0
- limit: 返回记录数限制,默认 100最大 100
**返回字段**
- id: 模型唯一标识符
- provider_id: 所属提供商 ID
- provider_name: 提供商名称
- name: 模型统一名称(优先使用 GlobalModel 名称)
- display_name: 模型显示名称
- description: 模型描述信息
- tags: 模型标签(当前为 null
- icon_url: 模型图标 URL
- input_price_per_1m: 输入价格(每 100 万 token
- output_price_per_1m: 输出价格(每 100 万 token
- cache_creation_price_per_1m: 缓存创建价格(每 100 万 token
- cache_read_price_per_1m: 缓存读取价格(每 100 万 token
- supports_vision: 是否支持视觉输入
- supports_function_calling: 是否支持函数调用
- supports_streaming: 是否支持流式输出
- is_active: 是否活跃
"""
adapter = PublicModelsAdapter(
provider_id=provider_id, is_active=is_active, skip=skip, limit=limit
)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=ApiMode.PUBLIC)
@router.get("/stats", response_model=ProviderStatsResponse)
async def get_public_stats(request: Request, db: Session = Depends(get_db)) -> Any:
"""
获取系统统计信息
返回系统的整体统计数据,包括提供商数量、模型数量和支持的 API 格式。
只统计活跃的提供商和模型。
**返回字段**
- total_providers: 活跃提供商总数
- active_providers: 活跃提供商数量(与 total_providers 相同)
- total_models: 活跃模型总数
- active_models: 活跃模型数量(与 total_models 相同)
- supported_formats: 支持的 API 格式列表(如 claude、openai、gemini 等)
"""
adapter = PublicStatsAdapter()
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=ApiMode.PUBLIC)
@router.get("/search/models")
async def search_models(
request: Request,
q: str = Query(..., description="搜索关键词"),
provider_id: int | None = Query(None, description="提供商ID过滤"),
limit: int = Query(20, description="返回记录数限制"),
db: Session = Depends(get_db),
) -> Any:
"""
搜索模型
根据关键词搜索模型,支持按模型名称、显示名称等字段进行模糊匹配。
只返回活跃提供商下的活跃模型。
**查询参数**
- q: 必填,搜索关键词,支持模糊匹配模型的 provider_model_name、GlobalModel.name 或 GlobalModel.display_name
- provider_id: 可选,按提供商 ID 过滤,只在该提供商下搜索
- limit: 返回记录数限制,默认 20最大值取决于系统配置
**返回字段**
返回符合条件的模型列表,字段与 /api/public/models 接口相同:
- id: 模型唯一标识符
- provider_id: 所属提供商 ID
- provider_name: 提供商名称
- provider_display_name: 提供商显示名称
- name: 模型统一名称
- display_name: 模型显示名称
- description: 模型描述
- tags: 模型标签
- icon_url: 模型图标 URL
- input_price_per_1m: 输入价格(每 100 万 token
- output_price_per_1m: 输出价格(每 100 万 token
- cache_creation_price_per_1m: 缓存创建价格(每 100 万 token
- cache_read_price_per_1m: 缓存读取价格(每 100 万 token
- supports_vision: 是否支持视觉
- supports_function_calling: 是否支持函数调用
- supports_streaming: 是否支持流式输出
- is_active: 是否活跃
"""
adapter = PublicSearchModelsAdapter(query=q, provider_id=provider_id, limit=limit)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=ApiMode.PUBLIC)
@router.get("/health/api-formats", response_model=PublicApiFormatHealthMonitorResponse)
async def get_public_api_format_health(
request: Request,
lookback_hours: int = Query(6, ge=1, le=168, description="回溯小时数"),
per_format_limit: int = Query(100, ge=10, le=500, description="每个格式的事件数限制"),
db: Session = Depends(get_db),
) -> Any:
"""
获取各 API 格式的健康监控数据
返回系统中各 API 格式(如 Claude、OpenAI、Gemini的健康状态和历史事件。
公开版本,不包含敏感信息(如 provider_id、key_id 等)。
**查询参数**
- lookback_hours: 回溯的时间范围(小时),默认 6 小时,范围 1-1687 天)
- per_format_limit: 每个 API 格式返回的历史事件数量上限,默认 100范围 10-500
**返回字段**
- generated_at: 响应生成时间
- formats: API 格式健康监控数据列表,每个格式包含:
- api_format: API 格式名称(如 claude、openai、gemini
- api_path: 本站入口路径
- total_attempts: 总请求尝试次数
- success_count: 成功次数
- failed_count: 失败次数
- skipped_count: 跳过次数
- success_rate: 成功率success / (success + failed)
- last_event_at: 最后事件时间
- events: 历史事件列表,按时间倒序,每个事件包含:
- timestamp: 事件时间
- status: 状态success、failed、skipped
- status_code: HTTP 状态码
- latency_ms: 延迟(毫秒)
- error_type: 错误类型(如果失败)
- timeline: 时间线数据,用于展示请求量趋势
- time_range_start: 时间范围起始
- time_range_end: 时间范围结束
"""
adapter = PublicApiFormatHealthMonitorAdapter(
lookback_hours=lookback_hours,
per_format_limit=per_format_limit,
)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=ApiMode.PUBLIC)
@router.get("/global-models", response_model=PublicGlobalModelListResponse)
async def get_public_global_models(
request: Request,
skip: int = Query(0, ge=0, description="跳过记录数"),
limit: int = Query(100, ge=1, le=1000, description="返回记录数限制"),
is_active: bool | None = Query(None, description="过滤活跃状态"),
search: str | None = Query(None, description="搜索关键词"),
db: Session = Depends(get_db),
) -> Any:
"""
获取全局模型GlobalModel列表
返回系统定义的全局模型列表,用于统一不同提供商的模型标识。
默认只返回活跃的全局模型。
**查询参数**
- skip: 跳过的记录数,用于分页,默认 0最小 0
- limit: 返回记录数限制,默认 100范围 1-1000
- is_active: 可选过滤活跃状态。None 表示只返回活跃模型True 返回活跃False 返回非活跃
- search: 可选搜索关键词支持模糊匹配模型名称name和显示名称display_name
**返回字段**
- models: 全局模型列表,每个模型包含:
- id: 全局模型唯一标识符UUID
- name: 模型名称(统一标识符)
- display_name: 模型显示名称
- is_active: 是否活跃
- default_price_per_request: 默认的按请求计价配置
- default_tiered_pricing: 默认的阶梯定价配置
- supported_capabilities: 支持的能力列表(如 vision、function_calling 等)
- config: 模型配置信息(如 description、icon_url 等)
- total: 符合条件的模型总数
"""
adapter = PublicGlobalModelsAdapter(
skip=skip,
limit=limit,
is_active=is_active,
search=search,
)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=ApiMode.PUBLIC)
# -------- 公共适配器 --------
class PublicApiAdapter(ApiAdapter):
mode = ApiMode.PUBLIC
def authorize(self, context: ApiRequestContext) -> None: # type: ignore[override]
return None
class PublicSiteInfoAdapter(PublicApiAdapter):
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
db = context.db
return {
"site_name": SystemConfigService.get_config(db, "site_name", default="Aether"),
"site_subtitle": SystemConfigService.get_config(
db,
"site_subtitle",
default="AI Gateway",
),
}
@dataclass
class PublicProvidersAdapter(PublicApiAdapter):
is_active: bool | None
skip: int
limit: int
@cache_result(
key_prefix="public:catalog:providers",
ttl=CacheTTL.PROVIDER,
user_specific=False,
vary_by=["is_active", "skip", "limit"],
)
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
db = context.db
logger.debug("公共API请求提供商列表")
query = db.query(Provider).options(
load_only(
Provider.id,
Provider.name,
Provider.description,
Provider.is_active,
Provider.provider_priority,
)
)
if self.is_active is not None:
query = query.filter(Provider.is_active == self.is_active)
else:
query = query.filter(Provider.is_active.is_(True))
providers = query.offset(self.skip).limit(self.limit).all()
provider_ids = [provider.id for provider in providers]
models_count_map: dict[str, int] = {}
active_models_count_map: dict[str, int] = {}
endpoints_count_map: dict[str, int] = {}
active_endpoints_count_map: dict[str, int] = {}
if provider_ids:
model_counts = (
db.query(Model.provider_id, func.count(Model.id))
.filter(Model.provider_id.in_(provider_ids))
.group_by(Model.provider_id)
.all()
)
models_count_map = {provider_id: int(count) for provider_id, count in model_counts}
active_model_counts = (
db.query(Model.provider_id, func.count(Model.id))
.filter(Model.provider_id.in_(provider_ids), Model.is_active.is_(True))
.group_by(Model.provider_id)
.all()
)
active_models_count_map = {
provider_id: int(count) for provider_id, count in active_model_counts
}
endpoint_counts = (
db.query(ProviderEndpoint.provider_id, func.count(ProviderEndpoint.id))
.filter(ProviderEndpoint.provider_id.in_(provider_ids))
.group_by(ProviderEndpoint.provider_id)
.all()
)
endpoints_count_map = {
provider_id: int(count) for provider_id, count in endpoint_counts
}
active_endpoint_counts = (
db.query(ProviderEndpoint.provider_id, func.count(ProviderEndpoint.id))
.filter(
ProviderEndpoint.provider_id.in_(provider_ids),
ProviderEndpoint.is_active.is_(True),
)
.group_by(ProviderEndpoint.provider_id)
.all()
)
active_endpoints_count_map = {
provider_id: int(count) for provider_id, count in active_endpoint_counts
}
result = []
for provider in providers:
models_count = models_count_map.get(provider.id, 0)
active_models_count = active_models_count_map.get(provider.id, 0)
endpoints_count = endpoints_count_map.get(provider.id, 0)
active_endpoints_count = active_endpoints_count_map.get(provider.id, 0)
provider_data = PublicProviderResponse(
id=provider.id,
name=provider.name,
description=provider.description,
is_active=provider.is_active,
provider_priority=provider.provider_priority,
models_count=models_count,
active_models_count=active_models_count,
endpoints_count=endpoints_count,
active_endpoints_count=active_endpoints_count,
)
result.append(provider_data.model_dump())
logger.debug(f"返回 {len(result)} 个提供商信息")
return result
@dataclass
class PublicModelsAdapter(PublicApiAdapter):
provider_id: str | None
is_active: bool | None
skip: int
limit: int
@cache_result(
key_prefix="public:catalog:models",
ttl=CacheTTL.ADMIN_USAGE_AGGREGATION,
user_specific=False,
vary_by=["provider_id", "is_active", "skip", "limit"],
)
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
db = context.db
logger.debug("公共API请求模型列表")
query = (
db.query(Model, Provider)
.options(joinedload(Model.global_model))
.join(Provider)
.filter(
and_(
Model.is_active.is_(True),
Provider.is_active.is_(True),
)
)
)
if self.provider_id is not None:
query = query.filter(Model.provider_id == self.provider_id)
results = query.offset(self.skip).limit(self.limit).all()
response = []
for model, provider in results:
global_model = model.global_model
display_name = global_model.display_name if global_model else model.provider_model_name
unified_name = global_model.name if global_model else model.provider_model_name
model_data = PublicModelResponse(
id=model.id,
provider_id=model.provider_id,
provider_name=provider.name,
name=unified_name,
display_name=display_name,
description=(
global_model.config.get("description")
if global_model and global_model.config
else None
),
tags=None,
icon_url=(
global_model.config.get("icon_url")
if global_model and global_model.config
else None
),
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(),
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=model.is_active,
)
response.append(model_data.model_dump())
logger.debug(f"返回 {len(response)} 个模型信息")
return response
class PublicStatsAdapter(PublicApiAdapter):
@cache_result(
key_prefix="public:catalog:stats",
ttl=CacheTTL.ADMIN_USAGE_AGGREGATION,
user_specific=False,
)
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
db = context.db
logger.debug("公共API请求系统统计信息")
active_providers = int(
db.query(func.count(Provider.id)).filter(Provider.is_active.is_(True)).scalar() or 0
)
active_models = int(
db.query(func.count(Model.id))
.join(Provider)
.filter(
and_(
Model.is_active.is_(True),
Provider.is_active.is_(True),
)
)
.scalar()
or 0
)
formats = (
db.query(ProviderEndpoint.api_format)
.join(Provider, ProviderEndpoint.provider_id == Provider.id)
.filter(
ProviderEndpoint.is_active.is_(True),
Provider.is_active.is_(True),
ProviderEndpoint.api_format.isnot(None),
)
.distinct()
.all()
)
supported_formats = [row[0] for row in formats if row[0]]
stats = ProviderStatsResponse(
total_providers=active_providers,
active_providers=active_providers,
total_models=active_models,
active_models=active_models,
supported_formats=supported_formats,
)
logger.debug("返回系统统计信息")
return stats.model_dump()
@dataclass
class PublicSearchModelsAdapter(PublicApiAdapter):
query: str
provider_id: int | None
limit: int
@cache_result(
key_prefix="public:catalog:search_models",
ttl=CacheTTL.ADMIN_USAGE_AGGREGATION,
user_specific=False,
vary_by=["query", "provider_id", "limit"],
)
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
db = context.db
logger.debug(f"公共API搜索模型: {self.query}")
query_stmt = (
db.query(Model, Provider)
.options(joinedload(Model.global_model))
.join(Provider)
.outerjoin(GlobalModel, Model.global_model_id == GlobalModel.id)
.filter(
and_(
Model.is_active.is_(True),
Provider.is_active.is_(True),
)
)
)
search_filter = (
Model.provider_model_name.ilike(f"%{self.query}%")
| GlobalModel.name.ilike(f"%{self.query}%")
| GlobalModel.display_name.ilike(f"%{self.query}%")
)
query_stmt = query_stmt.filter(search_filter)
if self.provider_id is not None:
query_stmt = query_stmt.filter(Model.provider_id == self.provider_id)
results = query_stmt.limit(self.limit).all()
response = []
for model, provider in results:
global_model = model.global_model
display_name = global_model.display_name if global_model else model.provider_model_name
unified_name = global_model.name if global_model else model.provider_model_name
model_data = PublicModelResponse(
id=model.id,
provider_id=model.provider_id,
provider_name=provider.name,
name=unified_name,
display_name=display_name,
description=(
global_model.config.get("description")
if global_model and global_model.config
else None
),
tags=None,
icon_url=(
global_model.config.get("icon_url")
if global_model and global_model.config
else None
),
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(),
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=model.is_active,
)
response.append(model_data.model_dump())
logger.debug(f"搜索 '{self.query}' 返回 {len(response)} 个结果")
return response
@dataclass
class PublicApiFormatHealthMonitorAdapter(PublicApiAdapter):
"""公开版 API 格式健康监控适配器(返回 events 数组,前端复用 EndpointHealthTimeline 组件)"""
lookback_hours: int
per_format_limit: int
@cache_result(
key_prefix="public:catalog:health_api_formats",
ttl=CacheTTL.ADMIN_USAGE_RECORDS,
user_specific=False,
vary_by=["lookback_hours", "per_format_limit"],
)
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. 获取所有活跃的 API 格式
active_formats = (
db.query(ProviderEndpoint.api_format)
.join(Provider, ProviderEndpoint.provider_id == Provider.id)
.filter(
ProviderEndpoint.is_active.is_(True),
Provider.is_active.is_(True),
)
.distinct()
.all()
)
all_formats: list[str] = []
for (api_format_enum,) in active_formats:
api_format = (
api_format_enum.value if hasattr(api_format_enum, "value") else str(api_format_enum)
)
all_formats.append(api_format)
# API 格式 -> Endpoint ID 映射(用于 Usage 时间线)
endpoint_rows = (
db.query(ProviderEndpoint.api_format, ProviderEndpoint.id)
.join(Provider, ProviderEndpoint.provider_id == Provider.id)
.filter(
ProviderEndpoint.is_active.is_(True),
Provider.is_active.is_(True),
)
.all()
)
endpoint_map: dict[str, list[str]] = defaultdict(list)
for api_format_enum, endpoint_id in endpoint_rows:
api_format = (
api_format_enum.value if hasattr(api_format_enum, "value") else str(api_format_enum)
)
endpoint_map[api_format].append(endpoint_id)
# 2. 统计窗口内每个 API 格式的真实状态分布
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:
api_format = (
api_format_enum.value if hasattr(api_format_enum, "value") else str(api_format_enum)
)
if api_format not in status_counts:
status_counts[api_format] = {"success": 0, "failed": 0, "skipped": 0}
status_counts[api_format][status] = count
# 3. 为所有活跃格式生成监控数据
monitors: list[PublicApiFormatHealthMonitor] = []
for api_format in all_formats:
candidates = _fetch_recent_public_health_attempts_for_api_format(
db=db,
api_format=api_format,
since=since,
per_format_limit=self.per_format_limit,
)
# 统计使用窗口内真实总数events 仅保留最近样本用于展示。
format_stats = status_counts.get(api_format, {"success": 0, "failed": 0, "skipped": 0})
success_count = format_stats.get("success", 0)
failed_count = format_stats.get("failed", 0)
skipped_count = format_stats.get("skipped", 0)
total_attempts = success_count + failed_count + skipped_count
# 计算成功率 = success / (success + failed)
actual_completed = success_count + failed_count
success_rate = success_count / actual_completed if actual_completed > 0 else 1.0
# 转换为公开版事件列表(不含敏感信息如 provider_id, key_id
events: list[PublicHealthEvent] = []
for c in candidates:
event_time = c.finished_at or c.started_at or c.created_at
events.append(
PublicHealthEvent(
timestamp=event_time,
status=c.status,
status_code=c.status_code,
latency_ms=c.latency_ms,
error_type=c.error_type,
)
)
# 最后事件时间
last_event_at = None
if candidates:
last_event_at = (
candidates[0].finished_at
or candidates[0].started_at
or candidates[0].created_at
)
timeline_data = EndpointHealthService._generate_timeline_from_usage(
db=db,
endpoint_ids=endpoint_map.get(api_format, []),
now=now,
lookback_hours=self.lookback_hours,
)
# 获取本站入口路径
from src.core.api_format import get_local_path_for_endpoint
local_path = get_local_path_for_endpoint(api_format)
monitors.append(
PublicApiFormatHealthMonitor(
api_format=api_format,
api_path=local_path,
total_attempts=total_attempts,
success_count=success_count,
failed_count=failed_count,
skipped_count=skipped_count,
success_rate=success_rate,
last_event_at=last_event_at,
events=events,
timeline=timeline_data.get("timeline", []),
time_range_start=timeline_data.get("time_range_start"),
time_range_end=timeline_data.get("time_range_end"),
)
)
response = PublicApiFormatHealthMonitorResponse(
generated_at=now,
formats=monitors,
)
logger.debug(f"公开健康监控: 返回 {len(monitors)} 个 API 格式的健康数据")
return response.model_dump()
@dataclass
class PublicGlobalModelsAdapter(PublicApiAdapter):
"""公开的 GlobalModel 列表适配器"""
skip: int
limit: int
is_active: bool | None
search: str | None
@cache_result(
key_prefix="public:catalog:global_models",
ttl=CacheTTL.MODEL,
user_specific=False,
vary_by=["skip", "limit", "is_active", "search"],
)
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
db = context.db
logger.debug("公共API请求 GlobalModel 列表")
query = db.query(GlobalModel)
# 默认只返回活跃的模型
if self.is_active is not None:
query = query.filter(GlobalModel.is_active == self.is_active)
else:
query = query.filter(GlobalModel.is_active.is_(True))
# 搜索过滤
if self.search:
search_term = f"%{self.search}%"
query = query.filter(
or_(
GlobalModel.name.ilike(search_term),
GlobalModel.display_name.ilike(search_term),
)
)
# 统计总数(避免 Query.count() 生成大子查询)
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()
# 转换为响应格式
model_responses = []
for gm in models:
model_responses.append(
PublicGlobalModelResponse(
id=gm.id,
name=gm.name,
display_name=gm.display_name,
is_active=gm.is_active,
default_price_per_request=gm.default_price_per_request,
default_tiered_pricing=gm.default_tiered_pricing,
supported_capabilities=gm.supported_capabilities,
config=gm.config,
)
)
logger.debug(f"返回 {len(model_responses)} 个 GlobalModel")
return PublicGlobalModelListResponse(models=model_responses, total=total).model_dump()

View File

@@ -0,0 +1,74 @@
"""
Claude API 端点
- /v1/messages - Claude Messages API
- /v1/messages/count_tokens - Token Count API
注意: /v1/models 端点由 models.py 统一处理,根据请求头返回对应格式
"""
from typing import Any
from fastapi import APIRouter, Depends, Request
from sqlalchemy.orm import Session
from src.api.base.pipeline import get_pipeline
from src.database import get_db
router = APIRouter(tags=["Claude API"])
pipeline = get_pipeline()
async def _run_claude_route_shell(adapter: Any, http_request: Request, db: Session) -> Any:
return await pipeline.run(
adapter=adapter,
http_request=http_request,
db=db,
mode=adapter.mode,
)
@router.post("/v1/messages")
async def create_message(
http_request: Request,
db: Session = Depends(get_db),
) -> Any:
"""
Claude Messages API
兼容 Anthropic Claude Messages API 格式的代理接口。
根据认证头自动在标准 API 和 Claude Code CLI 模式之间切换:
- x-api-key -> Chat 模式
- Authorization: Bearer -> CLI 模式
**请求格式**:
```json
{
"model": "claude-sonnet-4-20250514",
"max_tokens": 1024,
"messages": [{"role": "user", "content": "Hello"}]
}
```
"""
from src.api.handlers.claude import build_claude_adapter
adapter = build_claude_adapter(http_request)
return await _run_claude_route_shell(adapter, http_request, db)
@router.post("/v1/messages/count_tokens")
async def count_tokens(
http_request: Request,
db: Session = Depends(get_db),
) -> Any:
"""
Claude Token Count API
计算消息的 Token 数量,用于预估请求成本。
**认证方式**: x-api-key 请求头
"""
from src.api.handlers.claude import ClaudeTokenCountAdapter
adapter = ClaudeTokenCountAdapter()
return await _run_claude_route_shell(adapter, http_request, db)

View File

@@ -0,0 +1,26 @@
"""Rust-frontdoor-owned public compatibility route definitions."""
from fastapi import APIRouter
from .claude import router as claude_router
from .gemini import router as gemini_router
from .gemini_files import router as gemini_files_router
from .openai import router as openai_router
from .videos import router as videos_router
def build_frontdoor_compat_router() -> APIRouter:
"""Return public compat routes that Rust frontdoor owns at host level."""
compat_router = APIRouter()
compat_router.include_router(videos_router, tags=["Video Generation"])
compat_router.include_router(claude_router, tags=["Claude API"])
compat_router.include_router(openai_router)
compat_router.include_router(gemini_router, tags=["Gemini API"])
compat_router.include_router(gemini_files_router, tags=["Gemini Files API"])
return compat_router
frontdoor_compat_router = build_frontdoor_compat_router()
__all__ = ["build_frontdoor_compat_router", "frontdoor_compat_router"]

View File

@@ -0,0 +1,241 @@
"""
Gemini API 专属端点
托管 Gemini API 相关路由:
- /v1beta/models/{model}:generateContent
- /v1beta/models/{model}:streamGenerateContent
注意:
- Gemini API 的 model 在 URL 路径中,而不是请求体中
- /v1beta/models (列表) 和 /v1beta/models/{model} (详情) 由 models.py 统一处理
"""
from dataclasses import dataclass
from typing import Any
from fastapi import APIRouter, Depends, Request
from sqlalchemy.orm import Session
from src.api.base.adapter import ApiAdapter, ApiMode
from src.api.base.context import ApiRequestContext
from src.api.base.pipeline import get_pipeline
from src.database import get_db
router = APIRouter(tags=["Gemini API"])
pipeline = get_pipeline()
def _is_cli_request(request: Request) -> bool:
"""
判断是否为 CLI 请求
检查顺序:
1. x-app header 包含 "cli"
2. user-agent 包含 "GeminiCLI""gemini-cli"
"""
# 检查 x-app header
x_app = request.headers.get("x-app", "")
if "cli" in x_app.lower():
return True
# 检查 user-agent
user_agent = request.headers.get("user-agent", "")
user_agent_lower = user_agent.lower()
if "geminicli" in user_agent_lower or "gemini-cli" in user_agent_lower:
return True
return False
def _build_adapter_for_request(request: Request) -> Any:
"""按请求类型懒加载 Gemini 适配器,降低模块导入开销。"""
if _is_cli_request(request):
from src.api.handlers.gemini_cli import build_gemini_cli_adapter
return build_gemini_cli_adapter()
from src.api.handlers.gemini import build_gemini_adapter
return build_gemini_adapter()
@dataclass
class PublicGeminiContentAdapter(ApiAdapter):
model: str
stream: bool
name = "public.gemini.content"
mode = ApiMode.STANDARD
def _delegate(self, request: Request) -> Any:
return _build_adapter_for_request(request)
def extract_api_key(self, request: Request) -> str | None:
return self._delegate(request).extract_api_key(request)
def authorize(self, context: ApiRequestContext) -> None:
return self._delegate(context.request).authorize(context)
def detect_capability_requirements(
self,
headers: dict[str, str],
request_body: dict[str, Any] | None = None,
) -> dict[str, bool]:
adapter = _build_adapter_for_request(
Request(
{
"type": "http",
"asgi": {"version": "3.0"},
"http_version": "1.1",
"method": "POST",
"scheme": "http",
"path": "/",
"raw_path": b"/",
"query_string": b"",
"headers": [
(str(key).lower().encode(), str(value).encode()) for key, value in headers.items()
],
"client": ("127.0.0.1", 0),
"server": ("testserver", 80),
}
)
)
return adapter.detect_capability_requirements(headers, request_body)
def get_audit_metadata(
self,
context: ApiRequestContext,
*,
success: bool,
status_code: int | None,
error: str | None = None,
) -> dict[str, Any]:
return self._delegate(context.request).get_audit_metadata(
context,
success=success,
status_code=status_code,
error=error,
)
def api_format_hint_for_request(self, request: Request) -> str:
return self._delegate(request).allowed_api_formats[0]
def path_params(self) -> dict[str, Any]:
return {"model": self.model, "stream": self.stream}
async def handle(self, context: ApiRequestContext) -> Any:
return await self._delegate(context.request).handle(context)
@router.post("/v1beta/models/{model}:generateContent")
async def generate_content(
model: str,
http_request: Request,
db: Session = Depends(get_db),
) -> Any:
"""
Gemini generateContent API
兼容 Google Gemini API 格式的代理接口(非流式)。
**认证方式**:
- `x-goog-api-key` 请求头,或
- `?key=` URL 参数
**请求格式**:
```json
{
"contents": [{"parts": [{"text": "Hello"}]}]
}
```
**路径参数**:
- `model`: 模型名称,如 gemini-2.0-flash
"""
adapter = PublicGeminiContentAdapter(model=model, stream=False)
return await pipeline.run(
adapter=adapter,
http_request=http_request,
db=db,
mode=adapter.mode,
api_format_hint=adapter.api_format_hint_for_request(http_request),
path_params=adapter.path_params(),
)
@router.post("/v1beta/models/{model}:streamGenerateContent")
async def stream_generate_content(
model: str,
http_request: Request,
db: Session = Depends(get_db),
) -> Any:
"""
Gemini streamGenerateContent API
兼容 Google Gemini API 格式的代理接口(流式)。
**认证方式**:
- `x-goog-api-key` 请求头,或
- `?key=` URL 参数
**路径参数**:
- `model`: 模型名称,如 gemini-2.0-flash
注意: Gemini API 通过 URL 端点区分流式/非流式,不需要在请求体中添加 stream 字段
"""
adapter = PublicGeminiContentAdapter(model=model, stream=True)
return await pipeline.run(
adapter=adapter,
http_request=http_request,
db=db,
mode=adapter.mode,
api_format_hint=adapter.api_format_hint_for_request(http_request),
path_params=adapter.path_params(),
)
# 兼容 v1 路径(部分 SDK 可能使用 generateContent
@router.post("/v1/models/{model}:generateContent")
async def generate_content_v1(
model: str,
http_request: Request,
db: Session = Depends(get_db),
) -> Any:
"""
Gemini generateContent API (v1 兼容)
v1 版本 API 端点,兼容部分使用旧版路径的 SDK。
"""
adapter = PublicGeminiContentAdapter(model=model, stream=False)
return await pipeline.run(
adapter=adapter,
http_request=http_request,
db=db,
mode=adapter.mode,
api_format_hint=adapter.api_format_hint_for_request(http_request),
path_params=adapter.path_params(),
)
@router.post("/v1/models/{model}:streamGenerateContent")
async def stream_generate_content_v1(
model: str,
http_request: Request,
db: Session = Depends(get_db),
) -> Any:
"""
Gemini streamGenerateContent API (v1 兼容)
v1 版本流式 API 端点,兼容部分使用旧版路径的 SDK。
"""
adapter = PublicGeminiContentAdapter(model=model, stream=True)
return await pipeline.run(
adapter=adapter,
http_request=http_request,
db=db,
mode=adapter.mode,
api_format_hint=adapter.api_format_hint_for_request(http_request),
path_params=adapter.path_params(),
)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,797 @@
"""
统一的 Models API 端点
根据请求头认证方式自动返回对应格式:
- x-api-key + anthropic-version -> Claude 格式
- x-goog-api-key (header) 或 ?key= 参数 -> Gemini 格式
- Authorization: Bearer (bearer) -> OpenAI 格式
"""
from dataclasses import dataclass
from typing import Any
from fastapi import APIRouter, Depends, Query, Request
from fastapi.responses import JSONResponse
from sqlalchemy.orm import Session
from src.api.base.adapter import ApiAdapter, ApiMode
from src.api.base.context import ApiRequestContext
from src.api.base.pipeline import get_pipeline
from src.api.base.models_service import (
AccessRestrictions,
ModelInfo,
find_model_by_id,
get_available_provider_ids,
get_compatible_provider_formats,
list_available_models,
)
from src.core.api_format import (
detect_request_context,
)
from src.core.api_format.conversion import (
format_conversion_registry,
register_default_normalizers,
)
from src.core.logger import logger
from src.database import get_db
from src.models.database import ApiKey, User
from src.services.auth.service import AuthService
from src.services.provider.format import normalize_endpoint_signature
router = APIRouter(tags=["System Catalog"])
pipeline = get_pipeline()
# 各格式对应的 API 格式列表(包括对应的 CLI 格式)
_CLAUDE_FORMATS = ["claude:chat", "claude:cli"]
_OPENAI_FORMATS = ["openai:chat", "openai:cli", "openai:compact"]
_GEMINI_FORMATS = ["gemini:chat", "gemini:cli"]
# 所有格式(用于格式转换时的查询)
_ALL_CHAT_FORMATS = [
*_CLAUDE_FORMATS,
*_OPENAI_FORMATS,
*_GEMINI_FORMATS,
]
def _detect_api_format_and_key(request: Request) -> tuple[str, str | None]:
"""
根据请求头检测 API 格式并提取 API Key
检测顺序:
1. x-api-key + anthropic-version -> Claude
2. x-goog-api-key (header) 或 ?key= -> Gemini
3. Authorization: Bearer -> OpenAI (默认)
Returns:
(api_format, api_key) 元组
"""
context = detect_request_context(request)
return context.endpoint.key, context.credentials
def _get_formats_for_api(api_format: str) -> list[str]:
"""获取对应 API 格式的端点格式列表"""
fam = (api_format.split(":", 1)[0] if api_format else "").strip().lower()
if fam == "claude":
return _CLAUDE_FORMATS
if fam == "gemini":
return _GEMINI_FORMATS
return _OPENAI_FORMATS
def _is_format_conversion_enabled(db: Session) -> bool:
"""检查全局格式转换开关(从数据库配置读取,默认开启)"""
from src.services.system.config import SystemConfigService
return SystemConfigService.is_format_conversion_enabled(db)
def _get_convertible_formats(client_format: str) -> list[str]:
"""
获取客户端格式可转换到的所有目标格式列表
始终返回所有有转换器的格式(包括客户端格式本身),
由下游 get_compatible_provider_formats 按三层开关(全局/Provider/端点)精确过滤。
"""
client_format_norm = normalize_endpoint_signature(client_format)
# 收集所有可转换的格式
register_default_normalizers()
convertible_formats: list[str] = []
for target_format in _ALL_CHAT_FORMATS:
target_norm = normalize_endpoint_signature(target_format)
# 相同格式始终可用
if target_norm == client_format_norm:
convertible_formats.append(target_norm)
continue
# 检查是否有双向转换器
if format_conversion_registry.can_convert_full(
client_format_norm,
target_norm,
require_stream=False,
):
convertible_formats.append(target_norm)
# 去重并保持稳定顺序
return list(dict.fromkeys(convertible_formats)) if convertible_formats else [client_format_norm]
def _flatten_provider_formats(provider_to_formats: dict[str, set[str]]) -> list[str]:
"""合并 Provider 格式映射为唯一格式列表"""
if not provider_to_formats:
return []
all_formats: set[str] = set()
for formats in provider_to_formats.values():
all_formats.update(formats)
return sorted(all_formats)
def _get_family(api_format: str) -> str:
"""从 endpoint signature 提取协议族(如 'openai:chat' -> 'openai')。"""
return (str(api_format).split(":", 1)[0] if api_format else "").strip().lower()
def _build_empty_list_response(api_format: str) -> dict:
"""根据 API 格式构建空列表响应"""
fam = _get_family(api_format)
if fam == "claude":
return {"data": [], "has_more": False, "first_id": None, "last_id": None}
elif fam == "gemini":
return {"models": []}
else:
return {"object": "list", "data": []}
def _filter_formats_by_restrictions(
formats: list[str], restrictions: AccessRestrictions, api_format: str
) -> tuple[list[str], dict | None]:
"""
根据访问限制过滤 API 格式
Returns:
(过滤后的格式列表, 空响应或None)
如果过滤后为空,返回对应格式的空响应
"""
if restrictions.allowed_api_formats is None:
return formats, None
filtered = [f for f in formats if restrictions.is_api_format_allowed(f)]
if not filtered:
logger.info(f"[Models] API Key 不允许访问格式 {api_format}")
return [], _build_empty_list_response(api_format)
return filtered, None
def _authenticate(db: Session, api_key: str | None) -> tuple[User | None, ApiKey | None]:
"""
认证 API Key
Returns:
(user, api_key_record) 元组,认证失败返回 (None, None)
"""
if not api_key:
logger.debug("[Models] 认证失败: 未提供 API Key")
return None, None
result = AuthService.authenticate_api_key(db, api_key)
if not result:
logger.debug("[Models] 认证失败: API Key 无效")
return None, None
user, key_record = result
logger.debug(f"[Models] 认证成功: {user.email} (Key: {key_record.name})")
return result
def _build_auth_error_response(api_format: str) -> JSONResponse:
"""根据 API 格式构建认证错误响应"""
fam = _get_family(api_format)
if fam == "claude":
return JSONResponse(
status_code=401,
content={
"type": "error",
"error": {
"type": "authentication_error",
"message": "Invalid API key provided",
},
},
)
elif fam == "gemini":
return JSONResponse(
status_code=401,
content={
"error": {
"code": 401,
"message": "API key not valid. Please pass a valid API key.",
"status": "UNAUTHENTICATED",
}
},
)
else:
return JSONResponse(
status_code=401,
content={
"error": {
"message": "Incorrect API key provided. You can find your API key at https://platform.openai.com/account/api-keys.",
"type": "invalid_request_error",
"param": None,
"code": "invalid_api_key",
}
},
)
# ============================================================================
# 响应构建函数
# ============================================================================
def _build_claude_list_response(
models: list[ModelInfo],
before_id: str | None,
after_id: str | None,
limit: int,
) -> dict:
"""构建 Claude 格式的列表响应"""
model_data_list = [
{
"id": m.id,
"type": "model",
"display_name": m.display_name,
"created_at": m.created_at,
}
for m in models
]
# 处理分页
start_idx = 0
if after_id:
for i, m in enumerate(model_data_list):
if m["id"] == after_id:
start_idx = i + 1
break
end_idx = len(model_data_list)
if before_id:
for i, m in enumerate(model_data_list):
if m["id"] == before_id:
end_idx = i
break
paginated = model_data_list[start_idx:end_idx][:limit]
first_id = paginated[0]["id"] if paginated else None
last_id = paginated[-1]["id"] if paginated else None
has_more = len(model_data_list[start_idx:end_idx]) > limit
return {
"data": paginated,
"has_more": has_more,
"first_id": first_id,
"last_id": last_id,
}
def _build_openai_list_response(models: list[ModelInfo]) -> dict:
"""构建 OpenAI 格式的列表响应"""
data = [
{
"id": m.id,
"object": "model",
"created": m.created_timestamp,
"owned_by": m.provider_name,
}
for m in models
]
return {"object": "list", "data": data}
def _build_gemini_list_response(
models: list[ModelInfo],
page_size: int,
page_token: str | None,
) -> dict:
"""构建 Gemini 格式的列表响应"""
# 处理分页
start_idx = 0
if page_token:
try:
start_idx = int(page_token)
except ValueError:
start_idx = 0
end_idx = start_idx + page_size
paginated_models = models[start_idx:end_idx]
models_data = [
{
"name": f"models/{m.id}",
"baseModelId": m.id,
"version": "001",
"displayName": m.display_name,
"description": m.description or f"Model {m.id}",
"inputTokenLimit": m.context_limit if m.context_limit is not None else 128000,
"outputTokenLimit": m.output_limit if m.output_limit is not None else 8192,
"supportedGenerationMethods": ["generateContent", "countTokens"],
"temperature": 1.0,
"maxTemperature": 2.0,
"topP": 0.95,
"topK": 64,
}
for m in paginated_models
]
response: dict = {"models": models_data}
if end_idx < len(models):
response["nextPageToken"] = str(end_idx)
return response
def _build_claude_model_response(model_info: ModelInfo) -> dict:
"""构建 Claude 格式的模型详情响应"""
return {
"id": model_info.id,
"type": "model",
"display_name": model_info.display_name,
"created_at": model_info.created_at,
}
def _build_openai_model_response(model_info: ModelInfo) -> dict:
"""构建 OpenAI 格式的模型详情响应"""
return {
"id": model_info.id,
"object": "model",
"created": model_info.created_timestamp,
"owned_by": model_info.provider_name,
}
def _build_gemini_model_response(model_info: ModelInfo) -> dict:
"""构建 Gemini 格式的模型详情响应"""
return {
"name": f"models/{model_info.id}",
"baseModelId": model_info.id,
"version": "001",
"displayName": model_info.display_name,
"description": model_info.description or f"Model {model_info.id}",
"inputTokenLimit": (
model_info.context_limit if model_info.context_limit is not None else 128000
),
"outputTokenLimit": (
model_info.output_limit if model_info.output_limit is not None else 8192
),
"supportedGenerationMethods": ["generateContent", "countTokens"],
"temperature": 1.0,
"maxTemperature": 2.0,
"topP": 0.95,
"topK": 64,
}
# ============================================================================
# 404 响应
# ============================================================================
def _build_404_response(model_id: str, api_format: str) -> JSONResponse:
"""根据 API 格式构建 404 响应"""
fam = _get_family(api_format)
if fam == "claude":
return JSONResponse(
status_code=404,
content={
"type": "error",
"error": {"type": "not_found_error", "message": f"Model '{model_id}' not found"},
},
)
elif fam == "gemini":
return JSONResponse(
status_code=404,
content={
"error": {
"code": 404,
"message": f"models/{model_id} is not found",
"status": "NOT_FOUND",
}
},
)
else:
return JSONResponse(
status_code=404,
content={
"error": {
"message": f"The model '{model_id}' does not exist",
"type": "invalid_request_error",
"param": "model",
"code": "model_not_found",
}
},
)
# ============================================================================
# Adapter helpers
# ============================================================================
class PublicModelsApiAdapter(ApiAdapter):
mode = ApiMode.PUBLIC
eager_request_body = False
def authorize(self, context: ApiRequestContext) -> None: # type: ignore[override]
return None
def _handle_model_list_request(
context: ApiRequestContext,
*,
before_id: str | None,
after_id: str | None,
limit: int,
page_size: int,
page_token: str | None,
) -> dict | JSONResponse:
request = context.request
db = context.db
api_format, api_key = _detect_api_format_and_key(request)
logger.info(f"[Models] GET /v1/models | format={api_format}")
user, key_record = _authenticate(db, api_key)
if not user:
return _build_auth_error_response(api_format)
restrictions = AccessRestrictions.from_api_key_and_user(key_record, user)
global_conversion_enabled = _is_format_conversion_enabled(db)
candidate_formats = _get_convertible_formats(api_format)
candidate_formats, empty_response = _filter_formats_by_restrictions(
candidate_formats, restrictions, api_format
)
if empty_response is not None:
return empty_response
provider_to_formats = get_compatible_provider_formats(
db, api_format, candidate_formats, global_conversion_enabled
)
formats = _flatten_provider_formats(provider_to_formats)
available_provider_ids = get_available_provider_ids(db, formats, provider_to_formats)
if not available_provider_ids:
return _build_empty_list_response(api_format)
async def _list() -> dict:
models = await list_available_models(
db,
available_provider_ids,
formats,
restrictions,
provider_to_formats=provider_to_formats,
client_format=api_format,
)
logger.debug(f"[Models] 返回 {len(models)} 个模型")
if _get_family(api_format) == "claude":
return _build_claude_list_response(models, before_id, after_id, limit)
if _get_family(api_format) == "gemini":
return _build_gemini_list_response(models, page_size, page_token)
return _build_openai_list_response(models)
return _list()
def _handle_model_detail_request(
context: ApiRequestContext,
*,
model_id: str,
force_gemini_name: bool,
) -> dict | JSONResponse:
request = context.request
db = context.db
api_format, api_key = _detect_api_format_and_key(request)
resolved_model_id = model_id
if force_gemini_name:
resolved_model_id = model_id[7:] if model_id.startswith("models/") else model_id
logger.info(f"[Models] GET /v1beta/models/{resolved_model_id} | format=gemini")
else:
if _get_family(api_format) == "gemini" and resolved_model_id.startswith("models/"):
resolved_model_id = resolved_model_id[7:]
logger.info(f"[Models] GET /v1/models/{resolved_model_id} | format={api_format}")
user, key_record = _authenticate(db, api_key)
if not user:
return _build_auth_error_response(api_format)
restrictions = AccessRestrictions.from_api_key_and_user(key_record, user)
global_conversion_enabled = _is_format_conversion_enabled(db)
candidate_formats = _get_convertible_formats(api_format)
candidate_formats, _ = _filter_formats_by_restrictions(
candidate_formats, restrictions, api_format
)
provider_to_formats = get_compatible_provider_formats(
db, api_format, candidate_formats, global_conversion_enabled
)
formats = _flatten_provider_formats(provider_to_formats)
if not formats:
return _build_404_response(resolved_model_id, api_format)
available_provider_ids = get_available_provider_ids(db, formats, provider_to_formats)
model_info = find_model_by_id(
db,
resolved_model_id,
available_provider_ids,
formats,
restrictions,
provider_to_formats=provider_to_formats,
)
if not model_info:
return _build_404_response(resolved_model_id, api_format)
if _get_family(api_format) == "claude":
return _build_claude_model_response(model_info)
if _get_family(api_format) == "gemini":
return _build_gemini_model_response(model_info)
return _build_openai_model_response(model_info)
@dataclass
class PublicModelsListAdapter(PublicModelsApiAdapter):
before_id: str | None
after_id: str | None
limit: int
page_size: int
page_token: str | None
async def handle(self, context: ApiRequestContext) -> dict | JSONResponse: # type: ignore[override]
result = _handle_model_list_request(
context,
before_id=self.before_id,
after_id=self.after_id,
limit=self.limit,
page_size=self.page_size,
page_token=self.page_token,
)
if hasattr(result, "__await__"):
return await result
return result
@dataclass
class PublicModelDetailAdapter(PublicModelsApiAdapter):
model_id: str
force_gemini_name: bool = False
async def handle(self, context: ApiRequestContext) -> dict | JSONResponse: # type: ignore[override]
return _handle_model_detail_request(
context,
model_id=self.model_id,
force_gemini_name=self.force_gemini_name,
)
@router.get("/v1/models", response_model=None)
async def list_models(
request: Request,
# Claude 分页参数
before_id: str | None = Query(None, description="返回此 ID 之前的结果 (Claude)"),
after_id: str | None = Query(None, description="返回此 ID 之后的结果 (Claude)"),
limit: int = Query(20, ge=1, le=1000, description="返回数量限制 (Claude)"),
# Gemini 分页参数
page_size: int = Query(50, alias="pageSize", ge=1, le=1000, description="每页数量 (Gemini)"),
page_token: str | None = Query(None, alias="pageToken", description="分页 token (Gemini)"),
db: Session = Depends(get_db),
) -> dict | JSONResponse:
"""
列出可用模型(统一端点)
根据请求头中的认证方式自动检测 API 格式,并返回相应格式的模型列表。
此接口兼容 Claude、OpenAI 和 Gemini 三种 API 格式。
**格式检测规则**
- x-api-key + anthropic-version → Claude 格式
- x-goog-api-key 或 ?key= → Gemini 格式
- Authorization: Bearer → OpenAI 格式(默认)
**查询参数**
Claude 格式:
- before_id: 返回此 ID 之前的结果,用于向前分页
- after_id: 返回此 ID 之后的结果,用于向后分页
- limit: 返回数量限制,默认 20范围 1-1000
Gemini 格式:
- pageSize: 每页数量,默认 50范围 1-1000
- pageToken: 分页 token用于获取下一页
**返回字段**
Claude 格式:
- data: 模型列表,每个模型包含:
- id: 模型标识符
- type: "model"
- display_name: 显示名称
- created_at: 创建时间ISO 8601 格式)
- has_more: 是否有更多结果
- first_id: 当前页第一个模型 ID
- last_id: 当前页最后一个模型 ID
OpenAI 格式:
- object: "list"
- data: 模型列表,每个模型包含:
- id: 模型标识符
- object: "model"
- created: Unix 时间戳
- owned_by: 提供商名称
Gemini 格式:
- models: 模型列表,每个模型包含:
- name: 模型资源名称(如 models/gemini-pro
- baseModelId: 基础模型 ID
- version: 版本号
- displayName: 显示名称
- description: 描述信息
- inputTokenLimit: 输入 token 上限
- outputTokenLimit: 输出 token 上限
- supportedGenerationMethods: 支持的生成方法
- temperature: 默认温度参数
- maxTemperature: 最大温度参数
- topP: Top-P 参数
- topK: Top-K 参数
- nextPageToken: 下一页的 token如果有更多结果
**错误响应**
401: API Key 无效或未提供(格式根据检测到的 API 格式返回)
"""
adapter = PublicModelsListAdapter(
before_id=before_id,
after_id=after_id,
limit=limit,
page_size=page_size,
page_token=page_token,
)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=ApiMode.PUBLIC)
@router.get("/v1/models/{model_id:path}", response_model=None)
async def retrieve_model(
model_id: str,
request: Request,
db: Session = Depends(get_db),
) -> dict | JSONResponse:
"""
获取单个模型详情(统一端点)
根据请求头中的认证方式自动检测 API 格式,并返回相应格式的模型详情。
此接口兼容 Claude、OpenAI 和 Gemini 三种 API 格式。
**格式检测规则**
- x-api-key + anthropic-version → Claude 格式
- x-goog-api-key 或 ?key= → Gemini 格式
- Authorization: Bearer → OpenAI 格式(默认)
**路径参数**
- model_id: 模型标识符Gemini 格式支持 models/ 前缀,会自动移除)
**返回字段**
Claude 格式:
- id: 模型标识符
- type: "model"
- display_name: 显示名称
- created_at: 创建时间ISO 8601 格式)
OpenAI 格式:
- id: 模型标识符
- object: "model"
- created: Unix 时间戳
- owned_by: 提供商名称
Gemini 格式:
- name: 模型资源名称(如 models/gemini-pro
- baseModelId: 基础模型 ID
- version: 版本号
- displayName: 显示名称
- description: 描述信息
- inputTokenLimit: 输入 token 上限
- outputTokenLimit: 输出 token 上限
- supportedGenerationMethods: 支持的生成方法
- temperature: 默认温度参数
- maxTemperature: 最大温度参数
- topP: Top-P 参数
- topK: Top-K 参数
**错误响应**
401: API Key 无效或未提供
404: 模型不存在或不可访问
"""
adapter = PublicModelDetailAdapter(model_id=model_id)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=ApiMode.PUBLIC)
# Gemini 专用路径 /v1beta/models
@router.get("/v1beta/models", response_model=None)
async def list_models_gemini(
request: Request,
page_size: int = Query(50, alias="pageSize", ge=1, le=1000),
page_token: str | None = Query(None, alias="pageToken"),
db: Session = Depends(get_db),
) -> dict | JSONResponse:
"""
列出可用模型Gemini v1beta 专用端点)
Gemini API 的专用模型列表端点,使用 x-goog-api-key 或 ?key= 参数进行认证。
返回 Gemini 格式的模型列表。
**查询参数**
- pageSize: 每页数量,默认 50范围 1-1000
- pageToken: 分页 token用于获取下一页
**返回字段**
- models: 模型列表,每个模型包含:
- name: 模型资源名称(如 models/gemini-pro
- baseModelId: 基础模型 ID
- version: 版本号
- displayName: 显示名称
- description: 描述信息
- inputTokenLimit: 输入 token 上限
- outputTokenLimit: 输出 token 上限
- supportedGenerationMethods: 支持的生成方法列表
- temperature: 默认温度参数
- maxTemperature: 最大温度参数
- topP: Top-P 参数
- topK: Top-K 参数
- nextPageToken: 下一页的 token如果有更多结果
**错误响应**
401: API Key 无效或未提供
"""
adapter = PublicModelsListAdapter(
before_id=None,
after_id=None,
limit=20,
page_size=page_size,
page_token=page_token,
)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=ApiMode.PUBLIC)
@router.get("/v1beta/models/{model_name:path}", response_model=None)
async def get_model_gemini(
request: Request,
model_name: str,
db: Session = Depends(get_db),
) -> dict | JSONResponse:
"""
获取单个模型详情Gemini v1beta 专用端点)
Gemini API 的专用模型详情端点,使用 x-goog-api-key 或 ?key= 参数进行认证。
返回 Gemini 格式的模型详情。
**路径参数**
- model_name: 模型名称或资源路径(支持 models/ 前缀,会自动移除)
**返回字段**
- name: 模型资源名称(如 models/gemini-pro
- baseModelId: 基础模型 ID
- version: 版本号
- displayName: 显示名称
- description: 描述信息
- inputTokenLimit: 输入 token 上限
- outputTokenLimit: 输出 token 上限
- supportedGenerationMethods: 支持的生成方法列表
- temperature: 默认温度参数
- maxTemperature: 最大温度参数
- topP: Top-P 参数
- topK: Top-K 参数
**错误响应**
401: API Key 无效或未提供
404: 模型不存在或不可访问
"""
adapter = PublicModelDetailAdapter(model_id=model_name, force_gemini_name=True)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=ApiMode.PUBLIC)

View File

@@ -0,0 +1,62 @@
"""公开模块状态 API供登录页等使用"""
from typing import Any
from fastapi import APIRouter, Depends, Request
from pydantic import BaseModel
from sqlalchemy.orm import Session
from src.api.base.adapter import ApiAdapter, ApiMode
from src.api.base.context import ApiRequestContext
from src.api.base.pipeline import get_pipeline
from src.core.modules import get_module_registry
from src.database import get_db
router = APIRouter(prefix="/api/modules", tags=["Modules"])
pipeline = get_pipeline()
class AuthModuleInfo(BaseModel):
"""认证模块简要信息"""
name: str
display_name: str
active: bool
class PublicModulesApiAdapter(ApiAdapter):
mode = ApiMode.PUBLIC
def authorize(self, context: ApiRequestContext) -> None: # type: ignore[override]
return None
class PublicAuthModulesStatusAdapter(PublicModulesApiAdapter):
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
registry = get_module_registry()
auth_modules = registry.get_auth_modules_status(context.db)
return [
AuthModuleInfo(
name=status.name,
display_name=status.display_name,
active=status.active,
)
for status in auth_modules
]
@router.get("/auth-status", response_model=list[AuthModuleInfo])
async def get_auth_modules_status(request: Request, db: Session = Depends(get_db)) -> Any:
"""
获取认证模块状态(公开接口)
供登录页使用,返回所有可用的认证模块及其激活状态。
不需要认证即可访问。
**返回字段**:
- `name`: 模块名称
- `display_name`: 显示名称
- `active`: 是否激活
"""
adapter = PublicAuthModulesStatusAdapter()
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=ApiMode.PUBLIC)

View File

@@ -0,0 +1,95 @@
"""
OpenAI API 端点
- /v1/chat/completions - OpenAI Chat API
- /v1/responses - OpenAI Responses API (CLI)
- /v1/responses/compact - OpenAI Responses Compaction API (CLI)
注意: /v1/models 端点由 models.py 统一处理,根据请求头返回对应格式
"""
from typing import Any
from fastapi import APIRouter, Depends, Request
from sqlalchemy.orm import Session
from src.api.base.pipeline import get_pipeline
from src.database import get_db
router = APIRouter(tags=["OpenAI API"])
pipeline = get_pipeline()
async def _run_openai_route_shell(adapter: Any, http_request: Request, db: Session) -> Any:
return await pipeline.run(
adapter=adapter,
http_request=http_request,
db=db,
mode=adapter.mode,
)
@router.post("/v1/chat/completions")
async def create_chat_completion(
http_request: Request,
db: Session = Depends(get_db),
) -> Any:
"""
OpenAI Chat Completions API
兼容 OpenAI Chat Completions API 格式的代理接口。
**认证方式**: Bearer TokenAPI Key 或 JWT Token
**请求格式**:
```json
{
"model": "gpt-4",
"messages": [{"role": "user", "content": "Hello"}],
"stream": false
}
```
**支持的参数**: model, messages, stream, temperature, max_tokens 等标准 OpenAI 参数
"""
from src.api.handlers.openai import OpenAIChatAdapter
adapter = OpenAIChatAdapter()
return await _run_openai_route_shell(adapter, http_request, db)
@router.post("/v1/responses/compact")
async def create_responses_compact(
http_request: Request,
db: Session = Depends(get_db),
) -> Any:
"""
OpenAI Responses Compaction API (CLI)
用于压缩/总结之前的 responses永远非流式。
Codex CLI 使用 compact 模型后缀(如 gpt-5-compact时调用此端点。
**认证方式**: Bearer TokenAPI Key 或 JWT Token
"""
from src.api.handlers.openai_cli import OpenAICompactAdapter
adapter = OpenAICompactAdapter()
return await _run_openai_route_shell(adapter, http_request, db)
@router.post("/v1/responses")
async def create_responses(
http_request: Request,
db: Session = Depends(get_db),
) -> Any:
"""
OpenAI Responses API (CLI)
兼容 OpenAI Codex CLI 使用的 Responses API 格式,请求透传到上游。
**认证方式**: Bearer TokenAPI Key 或 JWT Token
"""
from src.api.handlers.openai_cli import OpenAICliAdapter
adapter = OpenAICliAdapter()
return await _run_openai_route_shell(adapter, http_request, db)

View File

@@ -0,0 +1,18 @@
"""Python-hosted public support route definitions."""
from fastapi import APIRouter
from .catalog import python_host_router as catalog_python_host_router
def build_python_public_support_router() -> APIRouter:
"""Return public routes that still belong to the Python host."""
support_router = APIRouter()
support_router.include_router(catalog_python_host_router)
return support_router
python_public_support_router = build_python_public_support_router()
__all__ = ["build_python_public_support_router", "python_public_support_router"]

View File

@@ -0,0 +1,694 @@
"""
System Catalog / 健康检查相关端点
这些是系统工具端点,不需要复杂的 Adapter 抽象。
"""
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Any
import httpx
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from sqlalchemy import func
from sqlalchemy.orm import Session, load_only, selectinload
from src.api.base.adapter import ApiAdapter, ApiMode
from src.api.base.context import ApiRequestContext
from src.api.base.pipeline import get_pipeline
from src.api.handlers.base.request_builder import (
PassthroughRequestBuilder,
build_test_request_body,
get_provider_auth,
)
from src.clients.redis_client import get_redis_client
from src.config.settings import config
from src.core.logger import logger
from src.database import get_db
from src.database.database import get_pool_status
from src.models.database import GlobalModel, Model, Provider, ProviderAPIKey, ProviderEndpoint
from src.services.provider.provider_context import resolve_provider_proxy
from src.services.provider.transport import build_provider_url
router = APIRouter(tags=["System Catalog"])
pipeline = get_pipeline()
class PublicSystemCatalogApiAdapter(ApiAdapter):
mode = ApiMode.PUBLIC
def authorize(self, context: ApiRequestContext) -> None: # type: ignore[override]
return None
# ============== 辅助函数 ==============
def _as_bool(value: str | None, default: bool) -> bool:
"""将字符串转换为布尔值"""
if value is None:
return default
return value.lower() in {"1", "true", "yes", "on"}
def _serialize_provider(
provider: Provider,
include_models: bool,
include_endpoints: bool,
) -> dict[str, Any]:
"""序列化 Provider 对象"""
provider_data: dict[str, Any] = {
"id": provider.id,
"name": provider.name,
"is_active": provider.is_active,
"provider_priority": provider.provider_priority,
}
if include_endpoints:
provider_data["endpoints"] = [
{
"id": endpoint.id,
"base_url": endpoint.base_url,
"api_format": endpoint.api_format if endpoint.api_format else None,
"is_active": endpoint.is_active,
}
for endpoint in provider.endpoints or []
]
if include_models:
provider_data["models"] = [
{
"id": model.id,
"name": (
model.global_model.name if model.global_model else model.provider_model_name
),
"display_name": (
model.global_model.display_name
if model.global_model
else model.provider_model_name
),
"is_active": model.is_active,
"supports_streaming": model.supports_streaming,
}
for model in provider.models or []
if model.is_active
]
return provider_data
def _select_provider(db: Session, provider_name: str | None) -> Provider | None:
"""选择 Provider按 provider_priority 优先级选择)"""
query = db.query(Provider).filter(Provider.is_active.is_(True))
if provider_name:
provider = query.filter(Provider.name == provider_name).first()
if provider:
return provider
# 按优先级选择provider_priority 最小的优先)
return query.order_by(Provider.provider_priority.asc()).first()
async def _build_test_connection_transport_context(
endpoint: ProviderEndpoint,
key: ProviderAPIKey,
) -> tuple[dict[str, Any] | None, dict[str, Any] | None, Any]:
from src.services.proxy_node.resolver import (
build_proxy_url_async,
get_system_proxy_config_async,
resolve_delegate_config_async,
resolve_effective_proxy,
resolve_proxy_info_async,
)
from src.services.request.execution_runtime_plan import ExecutionProxySnapshot
try:
effective_proxy = resolve_effective_proxy(
resolve_provider_proxy(endpoint=endpoint, key=key),
getattr(key, "proxy", None),
)
if not effective_proxy or not effective_proxy.get("enabled", True):
effective_proxy = await get_system_proxy_config_async()
delegate_cfg = await resolve_delegate_config_async(effective_proxy)
proxy_url: str | None = None
if effective_proxy and not (delegate_cfg and delegate_cfg.get("tunnel")):
proxy_url = await build_proxy_url_async(effective_proxy)
proxy_info = await resolve_proxy_info_async(effective_proxy)
proxy_snapshot = ExecutionProxySnapshot.from_proxy_info(
proxy_info,
proxy_url=proxy_url,
mode_override="tunnel" if delegate_cfg and delegate_cfg.get("tunnel") else None,
node_id_override=(
str(delegate_cfg.get("node_id") or "").strip() or None
if delegate_cfg and delegate_cfg.get("tunnel")
else None
),
)
return effective_proxy, delegate_cfg, proxy_snapshot
except Exception as exc:
logger.warning(
"Failed to build test-connection transport context endpoint={} key={}: {}",
getattr(endpoint, "id", None),
getattr(key, "id", None),
exc,
)
return None, None, None
async def _try_rust_test_connection_response(
*,
request_id: str,
url: str,
headers: dict[str, str],
body: dict[str, Any],
provider_name: str,
provider_id: str | None,
endpoint_id: str | None,
key_id: str | None,
api_format: str,
model_name: str,
proxy_snapshot: Any,
) -> httpx.Response:
import json
from src.services.request.execution_runtime_plan import (
ExecutionPlan,
ExecutionPlanTimeouts,
build_execution_plan_body,
)
from src.services.request.execution_runtime_client import (
ExecutionRuntimeClient,
ExecutionRuntimeClientError,
)
if config.execution_runtime_backend != "rust":
raise HTTPException(
status_code=503,
detail="System catalog test-connection requires Rust executor",
)
try:
result = await ExecutionRuntimeClient().execute_sync_json(
ExecutionPlan(
request_id=request_id,
candidate_id=None,
provider_name=provider_name,
provider_id=str(provider_id or ""),
endpoint_id=str(endpoint_id or ""),
key_id=str(key_id or ""),
method="POST",
url=url,
headers=dict(headers),
body=build_execution_plan_body(body, content_type="application/json"),
stream=False,
provider_api_format=api_format,
client_api_format=api_format,
model_name=model_name,
content_type="application/json",
proxy=proxy_snapshot,
timeouts=ExecutionPlanTimeouts(
connect_ms=30_000,
read_ms=30_000,
write_ms=30_000,
pool_ms=30_000,
total_ms=30_000,
),
)
)
except (ExecutionRuntimeClientError, httpx.HTTPError, json.JSONDecodeError) as exc:
logger.warning("Rust test-connection unavailable url={}: {}", url, exc)
raise HTTPException(
status_code=503,
detail="System catalog test-connection requires Rust executor",
) from exc
response_headers = dict(result.headers)
if result.response_json is not None:
response_headers.setdefault("content-type", "application/json")
response_body = json.dumps(result.response_json, ensure_ascii=False).encode("utf-8")
elif result.response_body_bytes is not None:
response_body = result.response_body_bytes
else:
response_body = b""
return httpx.Response(
status_code=result.status_code,
request=httpx.Request("POST", url, headers=headers),
headers=response_headers,
content=response_body,
)
async def _service_health_response(db: Session) -> dict[str, Any]:
active_providers = (
db.query(func.count(Provider.id)).filter(Provider.is_active.is_(True)).scalar() or 0
)
active_models = db.query(func.count(Model.id)).filter(Model.is_active.is_(True)).scalar() or 0
redis_info: dict[str, Any] = {"status": "unknown"}
try:
redis = await get_redis_client()
if redis:
await redis.ping()
redis_info = {"status": "ok"}
else:
redis_info = {"status": "degraded", "message": "Redis client not initialized"}
except Exception as exc:
redis_info = {"status": "error", "message": str(exc)}
return {
"status": "ok",
"timestamp": datetime.now(timezone.utc).isoformat(),
"stats": {
"active_providers": active_providers,
"active_models": active_models,
},
"dependencies": {
"database": {"status": "ok"},
"redis": redis_info,
},
}
def _health_check_response() -> dict[str, Any]:
try:
pool_status = get_pool_status()
pool_health = {
"checked_out": pool_status["checked_out"],
"pool_size": pool_status["pool_size"],
"overflow": pool_status["overflow"],
"max_capacity": pool_status["max_capacity"],
"usage_rate": (
f"{(pool_status['checked_out'] / pool_status['max_capacity'] * 100):.1f}%"
if pool_status["max_capacity"] > 0
else "0.0%"
),
}
except Exception as e:
pool_health = {"error": str(e)}
return {
"status": "healthy",
"timestamp": datetime.now(timezone.utc).isoformat(),
"database_pool": pool_health,
}
def _root_response(db: Session) -> dict[str, Any]:
top_provider = (
db.query(Provider)
.options(load_only(Provider.id, Provider.name, Provider.provider_priority))
.filter(Provider.is_active.is_(True))
.order_by(Provider.provider_priority.asc())
.first()
)
active_providers = (
db.query(func.count(Provider.id)).filter(Provider.is_active.is_(True)).scalar() or 0
)
return {
"message": "AI Proxy with Modular Architecture v4.0.0",
"status": "running",
"current_provider": top_provider.name if top_provider else "None",
"available_providers": active_providers,
"config": {},
"endpoints": {
"messages": "/v1/messages",
"count_tokens": "/v1/messages/count_tokens",
"health": "/v1/health",
"providers": "/v1/providers",
"test_connection": "/v1/test-connection",
},
}
def _list_providers_response(
db: Session,
*,
include_models: bool,
include_endpoints: bool,
active_only: bool,
) -> dict[str, Any]:
load_options = [
load_only(Provider.id, Provider.name, Provider.is_active, Provider.provider_priority)
]
if include_models:
load_options.append(
selectinload(Provider.models)
.load_only(
Model.id,
Model.provider_model_name,
Model.is_active,
Model.supports_streaming,
Model.global_model_id,
)
.selectinload(Model.global_model)
.load_only(GlobalModel.id, GlobalModel.name, GlobalModel.display_name)
)
if include_endpoints:
load_options.append(
selectinload(Provider.endpoints).load_only(
ProviderEndpoint.id,
ProviderEndpoint.base_url,
ProviderEndpoint.api_format,
ProviderEndpoint.is_active,
)
)
base_query = db.query(Provider)
if load_options:
base_query = base_query.options(*load_options)
if active_only:
base_query = base_query.filter(Provider.is_active.is_(True))
base_query = base_query.order_by(Provider.provider_priority.asc(), Provider.name.asc())
providers = base_query.all()
return {
"providers": [
_serialize_provider(provider, include_models, include_endpoints)
for provider in providers
]
}
def _provider_detail_response(
db: Session,
*,
provider_identifier: str,
include_models: bool,
include_endpoints: bool,
) -> dict[str, Any]:
load_options = [
load_only(Provider.id, Provider.name, Provider.is_active, Provider.provider_priority)
]
if include_models:
load_options.append(
selectinload(Provider.models)
.load_only(
Model.id,
Model.provider_model_name,
Model.is_active,
Model.supports_streaming,
Model.global_model_id,
)
.selectinload(Model.global_model)
.load_only(GlobalModel.id, GlobalModel.name, GlobalModel.display_name)
)
if include_endpoints:
load_options.append(
selectinload(Provider.endpoints).load_only(
ProviderEndpoint.id,
ProviderEndpoint.base_url,
ProviderEndpoint.api_format,
ProviderEndpoint.is_active,
)
)
base_query = db.query(Provider)
if load_options:
base_query = base_query.options(*load_options)
provider = base_query.filter(
(Provider.id == provider_identifier) | (Provider.name == provider_identifier)
).first()
if not provider:
raise HTTPException(status_code=404, detail="Provider not found")
return _serialize_provider(provider, include_models, include_endpoints)
async def _test_connection_response(
*,
request: Request,
db: Session,
provider: str | None,
model: str,
api_format: str | None,
) -> dict[str, Any]:
selected_provider = _select_provider(db, provider)
if not selected_provider:
raise HTTPException(status_code=503, detail="No active provider available")
active_endpoints: list[ProviderEndpoint] = [
ep for ep in (selected_provider.endpoints or []) if getattr(ep, "is_active", False)
]
if not active_endpoints:
raise HTTPException(status_code=503, detail="Provider has no active endpoints")
if api_format:
endpoint = next(
(ep for ep in active_endpoints if (ep.api_format or "") == api_format),
None,
)
if not endpoint:
raise HTTPException(
status_code=400,
detail=f"Provider has no active endpoint for api_format={api_format}",
)
format_value = api_format
else:
endpoint = active_endpoints[0]
format_value = endpoint.api_format or "claude:chat"
active_keys: list[ProviderAPIKey] = [
k for k in (selected_provider.api_keys or []) if getattr(k, "is_active", False)
]
if not active_keys:
raise HTTPException(status_code=503, detail="Provider has no active api keys")
def _key_supports_format(k: ProviderAPIKey) -> bool:
formats = getattr(k, "api_formats", None)
if formats is None:
return True
if isinstance(formats, list):
return str(format_value) in {str(x) for x in formats}
return True
key = next((k for k in active_keys if _key_supports_format(k)), active_keys[0])
payload = build_test_request_body(
format_value,
request_data={
"model": model,
"messages": [{"role": "user", "content": "Health check"}],
"max_tokens": 5,
},
)
try:
auth_info = await get_provider_auth(endpoint, key)
request_builder = PassthroughRequestBuilder()
provider_payload, provider_headers = request_builder.build(
payload,
{},
endpoint,
key,
is_stream=False,
pre_computed_auth=auth_info.as_tuple() if auth_info else None,
)
url = build_provider_url(
endpoint,
query_params=dict(request.query_params),
path_params={"model": model},
is_stream=False,
key=key,
decrypted_auth_config=auth_info.decrypted_auth_config if auth_info else None,
)
proxy_config, delegate_cfg, proxy_snapshot = await _build_test_connection_transport_context(
endpoint,
key,
)
resp = await _try_rust_test_connection_response(
request_id=f"test-connection:{selected_provider.id}:{model}",
url=url,
headers=provider_headers,
body=provider_payload,
provider_name=selected_provider.name,
provider_id=getattr(selected_provider, "id", None),
endpoint_id=getattr(endpoint, "id", None),
key_id=getattr(key, "id", None),
api_format=format_value,
model_name=model,
proxy_snapshot=proxy_snapshot,
)
if resp is None:
raise HTTPException(
status_code=503,
detail="System catalog test-connection requires Rust executor",
)
resp.raise_for_status()
response = resp.json()
return {
"status": "success",
"provider": selected_provider.name,
"endpoint_id": getattr(endpoint, "id", None),
"api_format": format_value,
"timestamp": datetime.now(timezone.utc).isoformat(),
"response_id": response.get("id", "unknown"),
}
except HTTPException:
raise
except Exception as exc:
logger.error(f"API connectivity test failed: {exc}")
raise HTTPException(status_code=503, detail=str(exc))
class PublicServiceHealthAdapter(PublicSystemCatalogApiAdapter):
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
return await _service_health_response(context.db)
class PublicSimpleHealthCheckAdapter(PublicSystemCatalogApiAdapter):
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
del context
return _health_check_response()
class PublicRootCatalogAdapter(PublicSystemCatalogApiAdapter):
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
return _root_response(context.db)
@dataclass
class PublicProvidersListAdapter(PublicSystemCatalogApiAdapter):
include_models: bool
include_endpoints: bool
active_only: bool
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
return _list_providers_response(
context.db,
include_models=self.include_models,
include_endpoints=self.include_endpoints,
active_only=self.active_only,
)
@dataclass
class PublicProviderDetailAdapter(PublicSystemCatalogApiAdapter):
provider_identifier: str
include_models: bool
include_endpoints: bool
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
return _provider_detail_response(
context.db,
provider_identifier=self.provider_identifier,
include_models=self.include_models,
include_endpoints=self.include_endpoints,
)
@dataclass
class PublicTestConnectionAdapter(PublicSystemCatalogApiAdapter):
provider: str | None
model: str
api_format: str | None
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
return await _test_connection_response(
request=context.request,
db=context.db,
provider=self.provider,
model=self.model,
api_format=self.api_format,
)
# ============== 端点 ==============
@router.get("/v1/health")
async def service_health(request: Request, db: Session = Depends(get_db)) -> Any:
"""返回服务健康状态与依赖信息"""
adapter = PublicServiceHealthAdapter()
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=ApiMode.PUBLIC)
@router.get("/health")
async def health_check(request: Request, db: Session = Depends(get_db)) -> Any:
"""简单健康检查端点(无需认证)"""
adapter = PublicSimpleHealthCheckAdapter()
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=ApiMode.PUBLIC)
@router.get("/")
async def root(request: Request, db: Session = Depends(get_db)) -> Any:
"""Root endpoint - 服务信息概览"""
adapter = PublicRootCatalogAdapter()
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=ApiMode.PUBLIC)
@router.get("/v1/providers")
async def list_providers(
request: Request,
db: Session = Depends(get_db),
include_models: bool = Query(False),
include_endpoints: bool = Query(False),
active_only: bool = Query(True),
) -> Any:
"""列出所有 Provider"""
adapter = PublicProvidersListAdapter(
include_models=include_models,
include_endpoints=include_endpoints,
active_only=active_only,
)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=ApiMode.PUBLIC)
@router.get("/v1/providers/{provider_identifier}")
async def provider_detail(
provider_identifier: str,
request: Request,
db: Session = Depends(get_db),
include_models: bool = Query(False),
include_endpoints: bool = Query(False),
) -> Any:
"""获取单个 Provider 详情"""
adapter = PublicProviderDetailAdapter(
provider_identifier=provider_identifier,
include_models=include_models,
include_endpoints=include_endpoints,
)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=ApiMode.PUBLIC)
@router.get("/v1/test-connection")
async def test_connection(
request: Request,
db: Session = Depends(get_db),
provider: str | None = Query(None),
model: str = Query("claude-3-haiku-20240307"),
api_format: str | None = Query(None),
) -> Any:
"""测试 Provider 连接"""
adapter = PublicTestConnectionAdapter(
provider=provider,
model=model,
api_format=api_format,
)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=ApiMode.PUBLIC)
@router.get("/test-connection")
async def test_connection_legacy(
request: Request,
db: Session = Depends(get_db),
provider: str | None = Query(None),
model: str = Query("claude-3-haiku-20240307"),
api_format: str | None = Query(None),
) -> Any:
"""测试 Provider 连接legacy alias已弃用"""
del request, db, provider, model, api_format
raise HTTPException(
status_code=410,
detail="Deprecated endpoint. Please use /v1/test-connection.",
)

View File

@@ -0,0 +1,178 @@
"""
Video Generation API 路由
"""
from __future__ import annotations
from typing import Any
from fastapi import APIRouter, Depends, Request
from sqlalchemy.orm import Session
from src.api.base.pipeline import get_pipeline
from src.api.handlers.gemini.video_adapter import GeminiVeoAdapter
from src.api.handlers.openai.video_adapter import OpenAIVideoAdapter
from src.database import get_db
router = APIRouter(tags=["Video Generation"])
pipeline = get_pipeline()
async def _run_video_route_shell(
adapter: Any,
http_request: Request,
db: Session,
*,
path_params: dict[str, Any] | None = None,
) -> Any:
return await pipeline.run(
adapter=adapter,
http_request=http_request,
db=db,
mode=adapter.mode,
api_format_hint=adapter.allowed_api_formats[0],
path_params=path_params,
)
# -------------------- OpenAI Sora compatible --------------------
@router.post("/v1/videos")
async def create_video_sora(http_request: Request, db: Session = Depends(get_db)) -> Any:
adapter = OpenAIVideoAdapter()
return await _run_video_route_shell(adapter, http_request, db)
@router.post("/v1/videos/{task_id}/cancel")
async def cancel_video_sora(
task_id: str, http_request: Request, db: Session = Depends(get_db)
) -> Any:
"""Cancel video task (OpenAI Sora style)."""
adapter = OpenAIVideoAdapter()
return await _run_video_route_shell(
adapter,
http_request,
db,
path_params={"task_id": task_id, "action": "cancel"},
)
@router.get("/v1/videos/{task_id}")
async def get_video_task_sora(
task_id: str, http_request: Request, db: Session = Depends(get_db)
) -> Any:
adapter = OpenAIVideoAdapter()
return await _run_video_route_shell(adapter, http_request, db, path_params={"task_id": task_id})
@router.get("/v1/videos")
async def list_video_tasks_sora(http_request: Request, db: Session = Depends(get_db)) -> Any:
adapter = OpenAIVideoAdapter()
return await _run_video_route_shell(adapter, http_request, db)
@router.delete("/v1/videos/{task_id}")
async def delete_video_task_sora(
task_id: str, http_request: Request, db: Session = Depends(get_db)
) -> Any:
"""删除已完成或失败的视频及其存储资源"""
adapter = OpenAIVideoAdapter()
return await _run_video_route_shell(adapter, http_request, db, path_params={"task_id": task_id})
@router.get("/v1/videos/{task_id}/content")
async def download_video_content_sora(
task_id: str, http_request: Request, db: Session = Depends(get_db)
) -> Any:
adapter = OpenAIVideoAdapter()
return await _run_video_route_shell(adapter, http_request, db, path_params={"task_id": task_id})
@router.post("/v1/videos/{task_id}/remix")
async def remix_video_sora(
task_id: str, http_request: Request, db: Session = Depends(get_db)
) -> Any:
adapter = OpenAIVideoAdapter()
return await _run_video_route_shell(adapter, http_request, db, path_params={"task_id": task_id})
# -------------------- Gemini Veo compatible --------------------
@router.post("/v1beta/models/{model}:predictLongRunning")
async def create_video_veo(model: str, http_request: Request, db: Session = Depends(get_db)) -> Any:
adapter = GeminiVeoAdapter()
return await _run_video_route_shell(adapter, http_request, db, path_params={"model": model})
# Gemini Veo operation routes - support both formats:
# 1. models/{model}/operations/{id} (official Gemini Veo format)
# 2. operations/{...} (legacy format for compatibility)
@router.get("/v1beta/models/{model}/operations/{operation_id}")
async def get_video_veo_by_model(
model: str, operation_id: str, http_request: Request, db: Session = Depends(get_db)
) -> Any:
"""Get video task status (Gemini Veo format: models/{model}/operations/{id})"""
adapter = GeminiVeoAdapter()
# Reconstruct full operation name
full_operation_name = f"models/{model}/operations/{operation_id}"
return await _run_video_route_shell(
adapter,
http_request,
db,
path_params={"task_id": full_operation_name},
)
@router.post("/v1beta/models/{model}/operations/{operation_id}:cancel")
async def cancel_video_veo_by_model(
model: str, operation_id: str, http_request: Request, db: Session = Depends(get_db)
) -> Any:
"""Cancel video task (Gemini Veo format: models/{model}/operations/{id}:cancel)"""
adapter = GeminiVeoAdapter()
full_operation_name = f"models/{model}/operations/{operation_id}"
return await _run_video_route_shell(
adapter,
http_request,
db,
path_params={"task_id": full_operation_name, "action": "cancel"},
)
# Legacy routes for backward compatibility
@router.get("/v1beta/operations/{operation_id:path}")
async def get_video_veo(
operation_id: str, http_request: Request, db: Session = Depends(get_db)
) -> Any:
adapter = GeminiVeoAdapter()
return await _run_video_route_shell(
adapter,
http_request,
db,
path_params={"task_id": operation_id},
)
@router.get("/v1beta/operations")
async def list_video_tasks_veo(http_request: Request, db: Session = Depends(get_db)) -> Any:
adapter = GeminiVeoAdapter()
return await _run_video_route_shell(adapter, http_request, db)
@router.post("/v1beta/operations/{operation_id}:cancel")
async def cancel_video_veo(
operation_id: str, http_request: Request, db: Session = Depends(get_db)
) -> Any:
adapter = GeminiVeoAdapter()
return await _run_video_route_shell(
adapter,
http_request,
db,
path_params={"task_id": operation_id, "action": "cancel"},
)
# Video download is now handled by /v1beta/files/{task_id}:download in gemini_files.py