mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 01:10:23 +08:00
refactor: 移除 Python 后端源码,全面迁移至 Rust gateway 架构
- 删除全部 Python 源码 (src/) 及 Alembic 迁移脚本,归档至 _deprecated_py_src/ - 重构 Rust gateway ai_pipeline: 拆分 planner/finalize 模块,新增 contracts/adaptation 层 - 重组 handlers 模块为 admin/public/proxy/internal/shared 子模块结构 - 新增 executor 模块,引入 Rust 原生数据库迁移 (aether-data/migrations) - 简化 CI/Docker 构建流程,移除 base image 二级构建,统一为单一 app image - 移除 Python 相关基础设施文件 (entrypoint.sh, gunicorn_conf.py, Dockerfile.base)
This commit is contained in:
24
_deprecated_py_src/api/public/__init__.py
Normal file
24
_deprecated_py_src/api/public/__init__.py
Normal 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}")
|
||||
193
_deprecated_py_src/api/public/capabilities.py
Normal file
193
_deprecated_py_src/api/public/capabilities.py
Normal 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,
|
||||
)
|
||||
850
_deprecated_py_src/api/public/catalog.py
Normal file
850
_deprecated_py_src/api/public/catalog.py
Normal 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-168(7 天)
|
||||
- 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()
|
||||
74
_deprecated_py_src/api/public/claude.py
Normal file
74
_deprecated_py_src/api/public/claude.py
Normal 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)
|
||||
26
_deprecated_py_src/api/public/compat.py
Normal file
26
_deprecated_py_src/api/public/compat.py
Normal 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"]
|
||||
241
_deprecated_py_src/api/public/gemini.py
Normal file
241
_deprecated_py_src/api/public/gemini.py
Normal 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(),
|
||||
)
|
||||
1301
_deprecated_py_src/api/public/gemini_files.py
Normal file
1301
_deprecated_py_src/api/public/gemini_files.py
Normal file
File diff suppressed because it is too large
Load Diff
797
_deprecated_py_src/api/public/models.py
Normal file
797
_deprecated_py_src/api/public/models.py
Normal 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)
|
||||
62
_deprecated_py_src/api/public/modules.py
Normal file
62
_deprecated_py_src/api/public/modules.py
Normal 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)
|
||||
95
_deprecated_py_src/api/public/openai.py
Normal file
95
_deprecated_py_src/api/public/openai.py
Normal 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 Token(API 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 Token(API 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 Token(API Key 或 JWT Token)
|
||||
"""
|
||||
from src.api.handlers.openai_cli import OpenAICliAdapter
|
||||
|
||||
adapter = OpenAICliAdapter()
|
||||
return await _run_openai_route_shell(adapter, http_request, db)
|
||||
18
_deprecated_py_src/api/public/support.py
Normal file
18
_deprecated_py_src/api/public/support.py
Normal 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"]
|
||||
694
_deprecated_py_src/api/public/system_catalog.py
Normal file
694
_deprecated_py_src/api/public/system_catalog.py
Normal 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.",
|
||||
)
|
||||
178
_deprecated_py_src/api/public/videos.py
Normal file
178
_deprecated_py_src/api/public/videos.py
Normal 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
|
||||
Reference in New Issue
Block a user