Files
Aether/_deprecated_py_src/api/public/catalog.py

851 lines
33 KiB
Python
Raw Normal View History

2025-12-10 20:52:44 +08:00
"""
公开API端点 - 用户可查看的提供商和模型信息
不包含敏感信息普通用户可访问
"""
from __future__ import annotations
2025-12-10 20:52:44 +08:00
from collections import defaultdict
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from typing import Any
2025-12-10 20:52:44 +08:00
from fastapi import APIRouter, Depends, Query, Request
from sqlalchemy import and_, func, or_
from sqlalchemy.orm import Session, joinedload, load_only
2025-12-10 20:52:44 +08:00
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
2025-12-10 20:52:44 +08:00
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
2025-12-10 20:52:44 +08:00
router = APIRouter(prefix="/api/public", tags=["System Catalog"])
python_host_router = APIRouter(prefix="/api/public", tags=["System Catalog"])
pipeline = get_pipeline()
2025-12-10 20:52:44 +08:00
feat: 流式空闲超时、健康监控查询优化、限流桶内存上限与维护清理修复 Close #233 Co-authored-by: AAEE86 <ppk0227@hotmail.com> - cli_monitor_mixin: 引入 STREAM_IDLE_TIMEOUT_SECONDS(可通过环境变量配置), 流传输开始后若超出空闲窗口无新 chunk 则提前取消并返回 504,避免长时间挂起 - stream_context: 新增 managed_recorded_bodies 上下文管理器,确保 chunks 在 telemetry 完成后及时释放;stream_telemetry 使用该接口统一管理 response body 构建 - health endpoint: 将状态聚合改为 GROUP BY 直接统计,事件列表按 api_format 单独查询,避免单次 limit 拉取大量记录导致的遗漏与性能问题;同时过滤不活跃 provider/endpoint,与公开健康接口保持一致 - endpoint health service: 修正时间线数据按 endpoint_id 而非 key_id 聚合 - token_bucket: 引入 max_buckets/bucket_expiry 上限与定时清理,防止内存无限增长; 修复 refill_rate=0 时 get_reset_time 除零异常;新增 _is_unlimited_rate_limit 判断 - maintenance_scheduler: 调整清理顺序(先删整行再按窗口清理),新增 newer_than 边界参数,避免同一行在同一轮中被重复改写 - sync_execute: 新增 create_pending_usage 开关,允许已预创建记录的调用方跳过重复创建 - quota_reader / provider_ops balance: 小幅修复与健壮性提升 - Dockerfile: 添加 MALLOC_ARENA_MAX=2 环境变量以降低 gunicorn worker RSS - 补充相关测试覆盖
2026-03-18 23:38:26 +08:00
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])
2025-12-10 20:52:44 +08:00
async def get_public_providers(
request: Request,
is_active: bool | None = Query(None, description="过滤活跃状态"),
2025-12-10 20:52:44 +08:00
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: 该提供商下活跃的端点数
"""
2025-12-10 20:52:44 +08:00
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])
2025-12-10 20:52:44 +08:00
async def get_public_models(
request: Request,
provider_id: str | None = Query(None, description="提供商ID过滤"),
is_active: bool | None = Query(None, description="过滤活跃状态"),
2025-12-10 20:52:44 +08:00
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: 是否活跃
"""
2025-12-10 20:52:44 +08:00
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 格式列表 claudeopenaigemini
"""
2025-12-10 20:52:44 +08:00
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过滤"),
2025-12-10 20:52:44 +08:00
limit: int = Query(20, description="返回记录数限制"),
db: Session = Depends(get_db),
) -> Any:
"""
搜索模型
根据关键词搜索模型支持按模型名称显示名称等字段进行模糊匹配
只返回活跃提供商下的活跃模型
**查询参数**
- q: 必填搜索关键词支持模糊匹配模型的 provider_model_nameGlobalModel.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: 是否活跃
"""
2025-12-10 20:52:44 +08:00
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 格式 ClaudeOpenAIGemini的健康状态和历史事件
公开版本不包含敏感信息 provider_idkey_id
**查询参数**
- lookback_hours: 回溯的时间范围小时默认 6 小时范围 1-1687
- per_format_limit: 每个 API 格式返回的历史事件数量上限默认 100范围 10-500
**返回字段**
- generated_at: 响应生成时间
- formats: API 格式健康监控数据列表每个格式包含
- api_format: API 格式名称 claudeopenaigemini
- api_path: 本站入口路径
- total_attempts: 总请求尝试次数
- success_count: 成功次数
- failed_count: 失败次数
- skipped_count: 跳过次数
- success_rate: 成功率success / (success + failed)
- last_event_at: 最后事件时间
- events: 历史事件列表按时间倒序每个事件包含
- timestamp: 事件时间
- status: 状态successfailedskipped
- status_code: HTTP 状态码
- latency_ms: 延迟毫秒
- error_type: 错误类型如果失败
- timeline: 时间线数据用于展示请求量趋势
- time_range_start: 时间范围起始
- time_range_end: 时间范围结束
"""
2025-12-10 20:52:44 +08:00
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="搜索关键词"),
2025-12-10 20:52:44 +08:00
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: 支持的能力列表 visionfunction_calling
- config: 模型配置信息 descriptionicon_url
- total: 符合条件的模型总数
"""
2025-12-10 20:52:44 +08:00
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]
2025-12-10 20:52:44 +08:00
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",
),
}
2025-12-10 20:52:44 +08:00
@dataclass
class PublicProvidersAdapter(PublicApiAdapter):
is_active: bool | None
2025-12-10 20:52:44 +08:00
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]
2025-12-10 20:52:44 +08:00
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,
)
)
2025-12-10 20:52:44 +08:00
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()
2025-12-10 20:52:44 +08:00
)
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)
2025-12-10 20:52:44 +08:00
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
2025-12-10 20:52:44 +08:00
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]
2025-12-10 20:52:44 +08:00
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),
)
)
2025-12-10 20:52:44 +08:00
)
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
),
2025-12-10 20:52:44 +08:00
tags=None,
icon_url=(
global_model.config.get("icon_url")
if global_model and global_model.config
else None
),
2025-12-10 20:52:44 +08:00
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]
2025-12-10 20:52:44 +08:00
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))
2025-12-10 20:52:44 +08:00
.join(Provider)
.filter(
and_(
Model.is_active.is_(True),
Provider.is_active.is_(True),
)
)
.scalar()
or 0
2025-12-10 20:52:44 +08:00
)
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()
2025-12-10 20:52:44 +08:00
)
supported_formats = [row[0] for row in formats if row[0]]
2025-12-10 20:52:44 +08:00
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
2025-12-10 20:52:44 +08:00
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]
2025-12-10 20:52:44 +08:00
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),
)
)
2025-12-10 20:52:44 +08:00
)
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
),
2025-12-10 20:52:44 +08:00
tags=None,
icon_url=(
global_model.config.get("icon_url")
if global_model and global_model.config
else None
),
2025-12-10 20:52:44 +08:00
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]
2025-12-10 20:52:44 +08:00
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] = []
2025-12-10 20:52:44 +08:00
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)
2025-12-10 20:52:44 +08:00
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)
feat: 流式空闲超时、健康监控查询优化、限流桶内存上限与维护清理修复 Close #233 Co-authored-by: AAEE86 <ppk0227@hotmail.com> - cli_monitor_mixin: 引入 STREAM_IDLE_TIMEOUT_SECONDS(可通过环境变量配置), 流传输开始后若超出空闲窗口无新 chunk 则提前取消并返回 504,避免长时间挂起 - stream_context: 新增 managed_recorded_bodies 上下文管理器,确保 chunks 在 telemetry 完成后及时释放;stream_telemetry 使用该接口统一管理 response body 构建 - health endpoint: 将状态聚合改为 GROUP BY 直接统计,事件列表按 api_format 单独查询,避免单次 limit 拉取大量记录导致的遗漏与性能问题;同时过滤不活跃 provider/endpoint,与公开健康接口保持一致 - endpoint health service: 修正时间线数据按 endpoint_id 而非 key_id 聚合 - token_bucket: 引入 max_buckets/bucket_expiry 上限与定时清理,防止内存无限增长; 修复 refill_rate=0 时 get_reset_time 除零异常;新增 _is_unlimited_rate_limit 判断 - maintenance_scheduler: 调整清理顺序(先删整行再按窗口清理),新增 newer_than 边界参数,避免同一行在同一轮中被重复改写 - sync_execute: 新增 create_pending_usage 开关,允许已预创建记录的调用方跳过重复创建 - quota_reader / provider_ops balance: 小幅修复与健壮性提升 - Dockerfile: 添加 MALLOC_ARENA_MAX=2 环境变量以降低 gunicorn worker RSS - 补充相关测试覆盖
2026-03-18 23:38:26 +08:00
# 2. 统计窗口内每个 API 格式的真实状态分布
2025-12-10 20:52:44 +08:00
final_statuses = ["success", "failed", "skipped"]
feat: 流式空闲超时、健康监控查询优化、限流桶内存上限与维护清理修复 Close #233 Co-authored-by: AAEE86 <ppk0227@hotmail.com> - cli_monitor_mixin: 引入 STREAM_IDLE_TIMEOUT_SECONDS(可通过环境变量配置), 流传输开始后若超出空闲窗口无新 chunk 则提前取消并返回 504,避免长时间挂起 - stream_context: 新增 managed_recorded_bodies 上下文管理器,确保 chunks 在 telemetry 完成后及时释放;stream_telemetry 使用该接口统一管理 response body 构建 - health endpoint: 将状态聚合改为 GROUP BY 直接统计,事件列表按 api_format 单独查询,避免单次 limit 拉取大量记录导致的遗漏与性能问题;同时过滤不活跃 provider/endpoint,与公开健康接口保持一致 - endpoint health service: 修正时间线数据按 endpoint_id 而非 key_id 聚合 - token_bucket: 引入 max_buckets/bucket_expiry 上限与定时清理,防止内存无限增长; 修复 refill_rate=0 时 get_reset_time 除零异常;新增 _is_unlimited_rate_limit 判断 - maintenance_scheduler: 调整清理顺序(先删整行再按窗口清理),新增 newer_than 边界参数,避免同一行在同一轮中被重复改写 - sync_execute: 新增 create_pending_usage 开关,允许已预创建记录的调用方跳过重复创建 - quota_reader / provider_ops balance: 小幅修复与健壮性提升 - Dockerfile: 添加 MALLOC_ARENA_MAX=2 环境变量以降低 gunicorn worker RSS - 补充相关测试覆盖
2026-03-18 23:38:26 +08:00
status_counts_query = (
2025-12-10 20:52:44 +08:00
db.query(
ProviderEndpoint.api_format,
feat: 流式空闲超时、健康监控查询优化、限流桶内存上限与维护清理修复 Close #233 Co-authored-by: AAEE86 <ppk0227@hotmail.com> - cli_monitor_mixin: 引入 STREAM_IDLE_TIMEOUT_SECONDS(可通过环境变量配置), 流传输开始后若超出空闲窗口无新 chunk 则提前取消并返回 504,避免长时间挂起 - stream_context: 新增 managed_recorded_bodies 上下文管理器,确保 chunks 在 telemetry 完成后及时释放;stream_telemetry 使用该接口统一管理 response body 构建 - health endpoint: 将状态聚合改为 GROUP BY 直接统计,事件列表按 api_format 单独查询,避免单次 limit 拉取大量记录导致的遗漏与性能问题;同时过滤不活跃 provider/endpoint,与公开健康接口保持一致 - endpoint health service: 修正时间线数据按 endpoint_id 而非 key_id 聚合 - token_bucket: 引入 max_buckets/bucket_expiry 上限与定时清理,防止内存无限增长; 修复 refill_rate=0 时 get_reset_time 除零异常;新增 _is_unlimited_rate_limit 判断 - maintenance_scheduler: 调整清理顺序(先删整行再按窗口清理),新增 newer_than 边界参数,避免同一行在同一轮中被重复改写 - sync_execute: 新增 create_pending_usage 开关,允许已预创建记录的调用方跳过重复创建 - quota_reader / provider_ops balance: 小幅修复与健壮性提升 - Dockerfile: 添加 MALLOC_ARENA_MAX=2 环境变量以降低 gunicorn worker RSS - 补充相关测试覆盖
2026-03-18 23:38:26 +08:00
RequestCandidate.status,
func.count(RequestCandidate.id).label("count"),
2025-12-10 20:52:44 +08:00
)
feat: 流式空闲超时、健康监控查询优化、限流桶内存上限与维护清理修复 Close #233 Co-authored-by: AAEE86 <ppk0227@hotmail.com> - cli_monitor_mixin: 引入 STREAM_IDLE_TIMEOUT_SECONDS(可通过环境变量配置), 流传输开始后若超出空闲窗口无新 chunk 则提前取消并返回 504,避免长时间挂起 - stream_context: 新增 managed_recorded_bodies 上下文管理器,确保 chunks 在 telemetry 完成后及时释放;stream_telemetry 使用该接口统一管理 response body 构建 - health endpoint: 将状态聚合改为 GROUP BY 直接统计,事件列表按 api_format 单独查询,避免单次 limit 拉取大量记录导致的遗漏与性能问题;同时过滤不活跃 provider/endpoint,与公开健康接口保持一致 - endpoint health service: 修正时间线数据按 endpoint_id 而非 key_id 聚合 - token_bucket: 引入 max_buckets/bucket_expiry 上限与定时清理,防止内存无限增长; 修复 refill_rate=0 时 get_reset_time 除零异常;新增 _is_unlimited_rate_limit 判断 - maintenance_scheduler: 调整清理顺序(先删整行再按窗口清理),新增 newer_than 边界参数,避免同一行在同一轮中被重复改写 - sync_execute: 新增 create_pending_usage 开关,允许已预创建记录的调用方跳过重复创建 - quota_reader / provider_ops balance: 小幅修复与健壮性提升 - Dockerfile: 添加 MALLOC_ARENA_MAX=2 环境变量以降低 gunicorn worker RSS - 补充相关测试覆盖
2026-03-18 23:38:26 +08:00
.join(RequestCandidate, ProviderEndpoint.id == RequestCandidate.endpoint_id)
.join(Provider, ProviderEndpoint.provider_id == Provider.id)
2025-12-10 20:52:44 +08:00
.filter(
feat: 流式空闲超时、健康监控查询优化、限流桶内存上限与维护清理修复 Close #233 Co-authored-by: AAEE86 <ppk0227@hotmail.com> - cli_monitor_mixin: 引入 STREAM_IDLE_TIMEOUT_SECONDS(可通过环境变量配置), 流传输开始后若超出空闲窗口无新 chunk 则提前取消并返回 504,避免长时间挂起 - stream_context: 新增 managed_recorded_bodies 上下文管理器,确保 chunks 在 telemetry 完成后及时释放;stream_telemetry 使用该接口统一管理 response body 构建 - health endpoint: 将状态聚合改为 GROUP BY 直接统计,事件列表按 api_format 单独查询,避免单次 limit 拉取大量记录导致的遗漏与性能问题;同时过滤不活跃 provider/endpoint,与公开健康接口保持一致 - endpoint health service: 修正时间线数据按 endpoint_id 而非 key_id 聚合 - token_bucket: 引入 max_buckets/bucket_expiry 上限与定时清理,防止内存无限增长; 修复 refill_rate=0 时 get_reset_time 除零异常;新增 _is_unlimited_rate_limit 判断 - maintenance_scheduler: 调整清理顺序(先删整行再按窗口清理),新增 newer_than 边界参数,避免同一行在同一轮中被重复改写 - sync_execute: 新增 create_pending_usage 开关,允许已预创建记录的调用方跳过重复创建 - quota_reader / provider_ops balance: 小幅修复与健壮性提升 - Dockerfile: 添加 MALLOC_ARENA_MAX=2 环境变量以降低 gunicorn worker RSS - 补充相关测试覆盖
2026-03-18 23:38:26 +08:00
ProviderEndpoint.is_active.is_(True),
Provider.is_active.is_(True),
2025-12-10 20:52:44 +08:00
RequestCandidate.created_at >= since,
RequestCandidate.status.in_(final_statuses),
)
feat: 流式空闲超时、健康监控查询优化、限流桶内存上限与维护清理修复 Close #233 Co-authored-by: AAEE86 <ppk0227@hotmail.com> - cli_monitor_mixin: 引入 STREAM_IDLE_TIMEOUT_SECONDS(可通过环境变量配置), 流传输开始后若超出空闲窗口无新 chunk 则提前取消并返回 504,避免长时间挂起 - stream_context: 新增 managed_recorded_bodies 上下文管理器,确保 chunks 在 telemetry 完成后及时释放;stream_telemetry 使用该接口统一管理 response body 构建 - health endpoint: 将状态聚合改为 GROUP BY 直接统计,事件列表按 api_format 单独查询,避免单次 limit 拉取大量记录导致的遗漏与性能问题;同时过滤不活跃 provider/endpoint,与公开健康接口保持一致 - endpoint health service: 修正时间线数据按 endpoint_id 而非 key_id 聚合 - token_bucket: 引入 max_buckets/bucket_expiry 上限与定时清理,防止内存无限增长; 修复 refill_rate=0 时 get_reset_time 除零异常;新增 _is_unlimited_rate_limit 判断 - maintenance_scheduler: 调整清理顺序(先删整行再按窗口清理),新增 newer_than 边界参数,避免同一行在同一轮中被重复改写 - sync_execute: 新增 create_pending_usage 开关,允许已预创建记录的调用方跳过重复创建 - quota_reader / provider_ops balance: 小幅修复与健壮性提升 - Dockerfile: 添加 MALLOC_ARENA_MAX=2 环境变量以降低 gunicorn worker RSS - 补充相关测试覆盖
2026-03-18 23:38:26 +08:00
.group_by(ProviderEndpoint.api_format, RequestCandidate.status)
2025-12-10 20:52:44 +08:00
.all()
)
feat: 流式空闲超时、健康监控查询优化、限流桶内存上限与维护清理修复 Close #233 Co-authored-by: AAEE86 <ppk0227@hotmail.com> - cli_monitor_mixin: 引入 STREAM_IDLE_TIMEOUT_SECONDS(可通过环境变量配置), 流传输开始后若超出空闲窗口无新 chunk 则提前取消并返回 504,避免长时间挂起 - stream_context: 新增 managed_recorded_bodies 上下文管理器,确保 chunks 在 telemetry 完成后及时释放;stream_telemetry 使用该接口统一管理 response body 构建 - health endpoint: 将状态聚合改为 GROUP BY 直接统计,事件列表按 api_format 单独查询,避免单次 limit 拉取大量记录导致的遗漏与性能问题;同时过滤不活跃 provider/endpoint,与公开健康接口保持一致 - endpoint health service: 修正时间线数据按 endpoint_id 而非 key_id 聚合 - token_bucket: 引入 max_buckets/bucket_expiry 上限与定时清理,防止内存无限增长; 修复 refill_rate=0 时 get_reset_time 除零异常;新增 _is_unlimited_rate_limit 判断 - maintenance_scheduler: 调整清理顺序(先删整行再按窗口清理),新增 newer_than 边界参数,避免同一行在同一轮中被重复改写 - sync_execute: 新增 create_pending_usage 开关,允许已预创建记录的调用方跳过重复创建 - quota_reader / provider_ops balance: 小幅修复与健壮性提升 - Dockerfile: 添加 MALLOC_ARENA_MAX=2 环境变量以降低 gunicorn worker RSS - 补充相关测试覆盖
2026-03-18 23:38:26 +08:00
status_counts: dict[str, dict[str, int]] = {}
for api_format_enum, status, count in status_counts_query:
2025-12-10 20:52:44 +08:00
api_format = (
api_format_enum.value if hasattr(api_format_enum, "value") else str(api_format_enum)
)
feat: 流式空闲超时、健康监控查询优化、限流桶内存上限与维护清理修复 Close #233 Co-authored-by: AAEE86 <ppk0227@hotmail.com> - cli_monitor_mixin: 引入 STREAM_IDLE_TIMEOUT_SECONDS(可通过环境变量配置), 流传输开始后若超出空闲窗口无新 chunk 则提前取消并返回 504,避免长时间挂起 - stream_context: 新增 managed_recorded_bodies 上下文管理器,确保 chunks 在 telemetry 完成后及时释放;stream_telemetry 使用该接口统一管理 response body 构建 - health endpoint: 将状态聚合改为 GROUP BY 直接统计,事件列表按 api_format 单独查询,避免单次 limit 拉取大量记录导致的遗漏与性能问题;同时过滤不活跃 provider/endpoint,与公开健康接口保持一致 - endpoint health service: 修正时间线数据按 endpoint_id 而非 key_id 聚合 - token_bucket: 引入 max_buckets/bucket_expiry 上限与定时清理,防止内存无限增长; 修复 refill_rate=0 时 get_reset_time 除零异常;新增 _is_unlimited_rate_limit 判断 - maintenance_scheduler: 调整清理顺序(先删整行再按窗口清理),新增 newer_than 边界参数,避免同一行在同一轮中被重复改写 - sync_execute: 新增 create_pending_usage 开关,允许已预创建记录的调用方跳过重复创建 - quota_reader / provider_ops balance: 小幅修复与健壮性提升 - Dockerfile: 添加 MALLOC_ARENA_MAX=2 环境变量以降低 gunicorn worker RSS - 补充相关测试覆盖
2026-03-18 23:38:26 +08:00
if api_format not in status_counts:
status_counts[api_format] = {"success": 0, "failed": 0, "skipped": 0}
status_counts[api_format][status] = count
2025-12-10 20:52:44 +08:00
# 3. 为所有活跃格式生成监控数据
monitors: list[PublicApiFormatHealthMonitor] = []
2025-12-10 20:52:44 +08:00
for api_format in all_formats:
feat: 流式空闲超时、健康监控查询优化、限流桶内存上限与维护清理修复 Close #233 Co-authored-by: AAEE86 <ppk0227@hotmail.com> - cli_monitor_mixin: 引入 STREAM_IDLE_TIMEOUT_SECONDS(可通过环境变量配置), 流传输开始后若超出空闲窗口无新 chunk 则提前取消并返回 504,避免长时间挂起 - stream_context: 新增 managed_recorded_bodies 上下文管理器,确保 chunks 在 telemetry 完成后及时释放;stream_telemetry 使用该接口统一管理 response body 构建 - health endpoint: 将状态聚合改为 GROUP BY 直接统计,事件列表按 api_format 单独查询,避免单次 limit 拉取大量记录导致的遗漏与性能问题;同时过滤不活跃 provider/endpoint,与公开健康接口保持一致 - endpoint health service: 修正时间线数据按 endpoint_id 而非 key_id 聚合 - token_bucket: 引入 max_buckets/bucket_expiry 上限与定时清理,防止内存无限增长; 修复 refill_rate=0 时 get_reset_time 除零异常;新增 _is_unlimited_rate_limit 判断 - maintenance_scheduler: 调整清理顺序(先删整行再按窗口清理),新增 newer_than 边界参数,避免同一行在同一轮中被重复改写 - sync_execute: 新增 create_pending_usage 开关,允许已预创建记录的调用方跳过重复创建 - quota_reader / provider_ops balance: 小幅修复与健壮性提升 - Dockerfile: 添加 MALLOC_ARENA_MAX=2 环境变量以降低 gunicorn worker RSS - 补充相关测试覆盖
2026-03-18 23:38:26 +08:00
candidates = _fetch_recent_public_health_attempts_for_api_format(
db=db,
api_format=api_format,
since=since,
per_format_limit=self.per_format_limit,
)
2025-12-10 20:52:44 +08:00
feat: 流式空闲超时、健康监控查询优化、限流桶内存上限与维护清理修复 Close #233 Co-authored-by: AAEE86 <ppk0227@hotmail.com> - cli_monitor_mixin: 引入 STREAM_IDLE_TIMEOUT_SECONDS(可通过环境变量配置), 流传输开始后若超出空闲窗口无新 chunk 则提前取消并返回 504,避免长时间挂起 - stream_context: 新增 managed_recorded_bodies 上下文管理器,确保 chunks 在 telemetry 完成后及时释放;stream_telemetry 使用该接口统一管理 response body 构建 - health endpoint: 将状态聚合改为 GROUP BY 直接统计,事件列表按 api_format 单独查询,避免单次 limit 拉取大量记录导致的遗漏与性能问题;同时过滤不活跃 provider/endpoint,与公开健康接口保持一致 - endpoint health service: 修正时间线数据按 endpoint_id 而非 key_id 聚合 - token_bucket: 引入 max_buckets/bucket_expiry 上限与定时清理,防止内存无限增长; 修复 refill_rate=0 时 get_reset_time 除零异常;新增 _is_unlimited_rate_limit 判断 - maintenance_scheduler: 调整清理顺序(先删整行再按窗口清理),新增 newer_than 边界参数,避免同一行在同一轮中被重复改写 - sync_execute: 新增 create_pending_usage 开关,允许已预创建记录的调用方跳过重复创建 - quota_reader / provider_ops balance: 小幅修复与健壮性提升 - Dockerfile: 添加 MALLOC_ARENA_MAX=2 环境变量以降低 gunicorn worker RSS - 补充相关测试覆盖
2026-03-18 23:38:26 +08:00
# 统计使用窗口内真实总数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
2025-12-10 20:52:44 +08:00
# 计算成功率 = 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] = []
2025-12-10 20:52:44 +08:00
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
2025-12-10 20:52:44 +08:00
local_path = get_local_path_for_endpoint(api_format)
2025-12-10 20:52:44 +08:00
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()
2025-12-10 20:52:44 +08:00
@dataclass
class PublicGlobalModelsAdapter(PublicApiAdapter):
"""公开的 GlobalModel 列表适配器"""
skip: int
limit: int
is_active: bool | None
search: str | None
2025-12-10 20:52:44 +08:00
@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]
2025-12-10 20:52:44 +08:00
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)
2025-12-10 20:52:44 +08:00
# 分页
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,
2025-12-10 20:52:44 +08:00
)
)
logger.debug(f"返回 {len(model_responses)} 个 GlobalModel")
return PublicGlobalModelListResponse(models=model_responses, total=total).model_dump()