2025-12-10 20:52:44 +08:00
|
|
|
|
"""
|
|
|
|
|
|
Provider 摘要与健康监控 API
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
from dataclasses import dataclass
|
|
|
|
|
|
from datetime import datetime, timedelta, timezone
|
2026-03-15 23:13:11 +08:00
|
|
|
|
from typing import Any, Protocol
|
2025-12-10 20:52:44 +08:00
|
|
|
|
|
|
|
|
|
|
from fastapi import APIRouter, Depends, Query, Request
|
|
|
|
|
|
from sqlalchemy import case, func
|
2026-03-03 22:04:40 +08:00
|
|
|
|
from sqlalchemy.orm import Session, load_only
|
2025-12-10 20:52:44 +08:00
|
|
|
|
|
|
|
|
|
|
from src.api.base.admin_adapter import AdminApiAdapter
|
2026-02-01 17:28:00 +08:00
|
|
|
|
from src.api.base.context import ApiRequestContext
|
2026-02-06 23:01:56 +08:00
|
|
|
|
from src.api.base.models_service import invalidate_models_list_cache
|
2026-03-14 11:59:07 +08:00
|
|
|
|
from src.api.base.pipeline import get_pipeline
|
2026-03-03 22:04:40 +08:00
|
|
|
|
from src.config.constants import CacheTTL
|
2025-12-10 20:52:44 +08:00
|
|
|
|
from src.core.enums import ProviderBillingType
|
2026-02-27 13:54:46 +08:00
|
|
|
|
from src.core.exceptions import InvalidRequestException, NotFoundException
|
2025-12-10 20:52:44 +08:00
|
|
|
|
from src.core.logger import logger
|
|
|
|
|
|
from src.database import get_db
|
2026-03-01 00:21:16 +08:00
|
|
|
|
from src.models.admin_requests import (
|
|
|
|
|
|
ClaudeCodeAdvancedConfig,
|
|
|
|
|
|
FailoverRulesConfig,
|
|
|
|
|
|
PoolAdvancedConfig,
|
|
|
|
|
|
)
|
2025-12-10 20:52:44 +08:00
|
|
|
|
from src.models.database import (
|
|
|
|
|
|
Model,
|
|
|
|
|
|
Provider,
|
|
|
|
|
|
ProviderAPIKey,
|
|
|
|
|
|
ProviderEndpoint,
|
|
|
|
|
|
RequestCandidate,
|
|
|
|
|
|
)
|
|
|
|
|
|
from src.models.endpoint_models import (
|
|
|
|
|
|
EndpointHealthEvent,
|
|
|
|
|
|
EndpointHealthMonitor,
|
|
|
|
|
|
ProviderEndpointHealthMonitorResponse,
|
2026-03-09 12:45:54 +08:00
|
|
|
|
ProviderSummaryPageResponse,
|
2025-12-10 20:52:44 +08:00
|
|
|
|
ProviderUpdateRequest,
|
|
|
|
|
|
ProviderWithEndpointsSummary,
|
|
|
|
|
|
)
|
2026-02-06 23:01:56 +08:00
|
|
|
|
from src.services.cache.model_cache import ModelCacheService
|
|
|
|
|
|
from src.services.cache.provider_cache import ProviderCacheService
|
2026-03-03 22:04:40 +08:00
|
|
|
|
from src.utils.cache_decorator import cache_result
|
2025-12-10 20:52:44 +08:00
|
|
|
|
|
2026-03-15 23:13:11 +08:00
|
|
|
|
|
|
|
|
|
|
class _HasProviderSortFields(Protocol):
|
|
|
|
|
|
is_active: Any
|
|
|
|
|
|
provider_priority: Any
|
|
|
|
|
|
created_at: Any
|
|
|
|
|
|
|
|
|
|
|
|
|
2025-12-10 20:52:44 +08:00
|
|
|
|
router = APIRouter(tags=["Provider Summary"])
|
2026-03-14 11:59:07 +08:00
|
|
|
|
pipeline = get_pipeline()
|
2025-12-10 20:52:44 +08:00
|
|
|
|
|
|
|
|
|
|
|
2026-03-15 23:13:11 +08:00
|
|
|
|
def _provider_summary_ordering(provider_model: _HasProviderSortFields) -> tuple[Any, Any, Any]:
|
|
|
|
|
|
"""Provider 摘要列表排序:启用在前,其次按优先级与创建时间。"""
|
|
|
|
|
|
return (
|
|
|
|
|
|
case((provider_model.is_active == True, 0), else_=1).asc(),
|
|
|
|
|
|
provider_model.provider_priority.asc(),
|
|
|
|
|
|
provider_model.created_at.asc(),
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-03-09 12:45:54 +08:00
|
|
|
|
@router.get("/summary", response_model=ProviderSummaryPageResponse)
|
2025-12-10 20:52:44 +08:00
|
|
|
|
async def get_providers_summary(
|
|
|
|
|
|
request: Request,
|
2026-03-09 12:45:54 +08:00
|
|
|
|
page: int = Query(1, ge=1),
|
|
|
|
|
|
page_size: int = Query(20, ge=1, le=10000),
|
|
|
|
|
|
search: str = Query("", description="按名称搜索"),
|
|
|
|
|
|
status: str = Query("all", description="all/active/inactive"),
|
|
|
|
|
|
api_format: str = Query("all", description="API 格式筛选"),
|
|
|
|
|
|
model_id: str = Query("all", description="全局模型 ID 筛选"),
|
2025-12-10 20:52:44 +08:00
|
|
|
|
db: Session = Depends(get_db),
|
2026-03-09 12:45:54 +08:00
|
|
|
|
) -> ProviderSummaryPageResponse:
|
|
|
|
|
|
"""获取提供商摘要信息(分页)"""
|
|
|
|
|
|
adapter = AdminProviderSummaryAdapter(
|
|
|
|
|
|
page=page,
|
|
|
|
|
|
page_size=page_size,
|
|
|
|
|
|
search=search,
|
|
|
|
|
|
status=status,
|
|
|
|
|
|
api_format=api_format,
|
|
|
|
|
|
model_id=model_id,
|
|
|
|
|
|
)
|
2025-12-10 20:52:44 +08:00
|
|
|
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/{provider_id}/summary", response_model=ProviderWithEndpointsSummary)
|
|
|
|
|
|
async def get_provider_summary(
|
|
|
|
|
|
provider_id: str,
|
|
|
|
|
|
request: Request,
|
|
|
|
|
|
db: Session = Depends(get_db),
|
|
|
|
|
|
) -> ProviderWithEndpointsSummary:
|
2026-01-07 14:55:07 +08:00
|
|
|
|
"""
|
|
|
|
|
|
获取单个提供商摘要信息
|
|
|
|
|
|
|
|
|
|
|
|
获取指定提供商的详细摘要信息,包含端点、密钥、模型统计和健康状态。
|
|
|
|
|
|
|
|
|
|
|
|
**路径参数**:
|
|
|
|
|
|
- `provider_id`: 提供商 ID
|
|
|
|
|
|
|
|
|
|
|
|
**返回字段**:
|
|
|
|
|
|
- `id`: 提供商 ID
|
|
|
|
|
|
- `name`: 提供商名称
|
|
|
|
|
|
- `description`: 描述信息
|
|
|
|
|
|
- `website`: 官网地址
|
|
|
|
|
|
- `provider_priority`: 优先级
|
|
|
|
|
|
- `is_active`: 是否启用
|
|
|
|
|
|
- `billing_type`: 计费类型
|
|
|
|
|
|
- `monthly_quota_usd`: 月度配额(美元)
|
|
|
|
|
|
- `monthly_used_usd`: 本月已使用金额(美元)
|
|
|
|
|
|
- `quota_reset_day`: 配额重置日期
|
|
|
|
|
|
- `quota_last_reset_at`: 上次配额重置时间
|
|
|
|
|
|
- `quota_expires_at`: 配额过期时间
|
2026-01-10 18:43:53 +08:00
|
|
|
|
- `timeout`: 默认请求超时(秒)
|
|
|
|
|
|
- `max_retries`: 默认最大重试次数
|
|
|
|
|
|
- `proxy`: 默认代理配置
|
2026-01-07 14:55:07 +08:00
|
|
|
|
- `total_endpoints`: 端点总数
|
|
|
|
|
|
- `active_endpoints`: 活跃端点数
|
|
|
|
|
|
- `total_keys`: 密钥总数
|
|
|
|
|
|
- `active_keys`: 活跃密钥数
|
|
|
|
|
|
- `total_models`: 模型总数
|
|
|
|
|
|
- `active_models`: 活跃模型数
|
|
|
|
|
|
- `avg_health_score`: 平均健康分数(0-1)
|
|
|
|
|
|
- `unhealthy_endpoints`: 不健康端点数(健康分数 < 0.5)
|
|
|
|
|
|
- `api_formats`: 支持的 API 格式列表
|
|
|
|
|
|
- `endpoint_health_details`: 端点健康详情(包含 api_format, health_score, is_active, active_keys)
|
|
|
|
|
|
- `created_at`: 创建时间
|
|
|
|
|
|
- `updated_at`: 更新时间
|
|
|
|
|
|
"""
|
2026-03-03 22:04:40 +08:00
|
|
|
|
adapter = AdminProviderDetailAdapter(provider_id=provider_id)
|
|
|
|
|
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
2025-12-10 20:52:44 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/{provider_id}/health-monitor", response_model=ProviderEndpointHealthMonitorResponse)
|
|
|
|
|
|
async def get_provider_health_monitor(
|
|
|
|
|
|
provider_id: str,
|
|
|
|
|
|
request: Request,
|
|
|
|
|
|
lookback_hours: int = Query(6, ge=1, le=72, description="回溯的小时数"),
|
|
|
|
|
|
per_endpoint_limit: int = Query(48, ge=10, le=200, description="每个端点的事件数量"),
|
|
|
|
|
|
db: Session = Depends(get_db),
|
|
|
|
|
|
) -> ProviderEndpointHealthMonitorResponse:
|
2026-01-07 14:55:07 +08:00
|
|
|
|
"""
|
|
|
|
|
|
获取提供商健康监控数据
|
|
|
|
|
|
|
|
|
|
|
|
获取指定提供商下所有端点的健康监控时间线,包含请求成功率、延迟、错误信息等。
|
|
|
|
|
|
|
|
|
|
|
|
**路径参数**:
|
|
|
|
|
|
- `provider_id`: 提供商 ID
|
|
|
|
|
|
|
|
|
|
|
|
**查询参数**:
|
|
|
|
|
|
- `lookback_hours`: 回溯的小时数,范围 1-72,默认为 6
|
|
|
|
|
|
- `per_endpoint_limit`: 每个端点返回的事件数量,范围 10-200,默认为 48
|
|
|
|
|
|
|
|
|
|
|
|
**返回字段**:
|
|
|
|
|
|
- `provider_id`: 提供商 ID
|
|
|
|
|
|
- `provider_name`: 提供商名称
|
|
|
|
|
|
- `generated_at`: 生成时间
|
|
|
|
|
|
- `endpoints`: 端点健康监控数据数组,每项包含:
|
|
|
|
|
|
- `endpoint_id`: 端点 ID
|
|
|
|
|
|
- `api_format`: API 格式
|
|
|
|
|
|
- `is_active`: 是否活跃
|
|
|
|
|
|
- `total_attempts`: 总请求次数
|
|
|
|
|
|
- `success_count`: 成功次数
|
|
|
|
|
|
- `failed_count`: 失败次数
|
|
|
|
|
|
- `skipped_count`: 跳过次数
|
|
|
|
|
|
- `success_rate`: 成功率(0-1)
|
|
|
|
|
|
- `last_event_at`: 最后事件时间
|
|
|
|
|
|
- `events`: 事件详情数组(包含 timestamp, status, status_code, latency_ms, error_type, error_message)
|
|
|
|
|
|
"""
|
2025-12-10 20:52:44 +08:00
|
|
|
|
|
|
|
|
|
|
adapter = AdminProviderHealthMonitorAdapter(
|
|
|
|
|
|
provider_id=provider_id,
|
|
|
|
|
|
lookback_hours=lookback_hours,
|
|
|
|
|
|
per_endpoint_limit=per_endpoint_limit,
|
|
|
|
|
|
)
|
|
|
|
|
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.patch("/{provider_id}", response_model=ProviderWithEndpointsSummary)
|
|
|
|
|
|
async def update_provider_settings(
|
|
|
|
|
|
provider_id: str,
|
|
|
|
|
|
update_data: ProviderUpdateRequest,
|
|
|
|
|
|
request: Request,
|
|
|
|
|
|
db: Session = Depends(get_db),
|
|
|
|
|
|
) -> ProviderWithEndpointsSummary:
|
2026-01-07 14:55:07 +08:00
|
|
|
|
"""
|
|
|
|
|
|
更新提供商基础配置
|
|
|
|
|
|
|
2026-01-10 18:43:53 +08:00
|
|
|
|
更新提供商的基础配置信息,如名称、描述、优先级等。只需传入需要更新的字段。
|
2026-01-07 14:55:07 +08:00
|
|
|
|
|
|
|
|
|
|
**路径参数**:
|
|
|
|
|
|
- `provider_id`: 提供商 ID
|
|
|
|
|
|
|
|
|
|
|
|
**请求体字段**(所有字段可选):
|
2026-01-10 18:43:53 +08:00
|
|
|
|
- `name`: 提供商名称
|
2026-01-07 14:55:07 +08:00
|
|
|
|
- `description`: 描述信息
|
|
|
|
|
|
- `website`: 官网地址
|
|
|
|
|
|
- `provider_priority`: 优先级
|
|
|
|
|
|
- `is_active`: 是否启用
|
|
|
|
|
|
- `billing_type`: 计费类型
|
|
|
|
|
|
- `monthly_quota_usd`: 月度配额(美元)
|
|
|
|
|
|
- `quota_reset_day`: 配额重置日期
|
|
|
|
|
|
- `quota_expires_at`: 配额过期时间
|
2026-01-10 18:43:53 +08:00
|
|
|
|
- `timeout`: 默认请求超时(秒)
|
|
|
|
|
|
- `max_retries`: 默认最大重试次数
|
|
|
|
|
|
- `proxy`: 默认代理配置
|
2026-01-07 14:55:07 +08:00
|
|
|
|
|
|
|
|
|
|
**返回字段**: 返回更新后的提供商摘要信息(与 GET /summary 接口返回格式相同)
|
|
|
|
|
|
"""
|
2025-12-10 20:52:44 +08:00
|
|
|
|
|
|
|
|
|
|
adapter = AdminUpdateProviderSettingsAdapter(provider_id=provider_id, update_data=update_data)
|
|
|
|
|
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-02-27 13:54:46 +08:00
|
|
|
|
def _extract_pool_advanced_from_config(
|
|
|
|
|
|
provider_config: dict[str, Any] | None,
|
|
|
|
|
|
*,
|
|
|
|
|
|
provider_id: str,
|
|
|
|
|
|
) -> PoolAdvancedConfig | None:
|
|
|
|
|
|
"""从 Provider.config 中安全提取通用号池配置。
|
|
|
|
|
|
|
|
|
|
|
|
优先查找 ``pool_advanced``,回退查找 ``claude_code_advanced`` 中的号池字段。
|
|
|
|
|
|
"""
|
|
|
|
|
|
cfg = provider_config or {}
|
|
|
|
|
|
raw = cfg.get("pool_advanced")
|
|
|
|
|
|
if raw is None:
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
if isinstance(raw, PoolAdvancedConfig):
|
|
|
|
|
|
return raw
|
|
|
|
|
|
|
|
|
|
|
|
if not isinstance(raw, dict):
|
|
|
|
|
|
logger.warning(
|
|
|
|
|
|
"Provider {} 的 pool_advanced 类型无效: {},已忽略",
|
|
|
|
|
|
provider_id,
|
|
|
|
|
|
type(raw).__name__,
|
|
|
|
|
|
)
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
return PoolAdvancedConfig.model_validate(raw)
|
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
|
logger.warning(
|
|
|
|
|
|
"Provider {} 的 pool_advanced 配置无效,已忽略: {}",
|
|
|
|
|
|
provider_id,
|
|
|
|
|
|
str(exc),
|
|
|
|
|
|
)
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _extract_claude_code_advanced_from_config(
|
|
|
|
|
|
provider_config: dict[str, Any] | None,
|
|
|
|
|
|
*,
|
|
|
|
|
|
provider_id: str,
|
|
|
|
|
|
) -> ClaudeCodeAdvancedConfig | None:
|
|
|
|
|
|
"""从 Provider.config 中安全提取 Claude Code 高级配置。"""
|
|
|
|
|
|
raw_config = (provider_config or {}).get("claude_code_advanced")
|
|
|
|
|
|
if raw_config is None:
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
if isinstance(raw_config, ClaudeCodeAdvancedConfig):
|
|
|
|
|
|
return raw_config
|
|
|
|
|
|
|
|
|
|
|
|
if not isinstance(raw_config, dict):
|
|
|
|
|
|
logger.warning(
|
|
|
|
|
|
"Provider {} 的 claude_code_advanced 类型无效: {},已忽略",
|
|
|
|
|
|
provider_id,
|
|
|
|
|
|
type(raw_config).__name__,
|
|
|
|
|
|
)
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
return ClaudeCodeAdvancedConfig.model_validate(raw_config)
|
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
|
logger.warning(
|
|
|
|
|
|
"Provider {} 的 claude_code_advanced 配置无效,已忽略: {}",
|
|
|
|
|
|
provider_id,
|
|
|
|
|
|
str(exc),
|
|
|
|
|
|
)
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-03-01 00:21:16 +08:00
|
|
|
|
def _extract_failover_rules_from_config(
|
|
|
|
|
|
provider_config: dict[str, Any] | None,
|
|
|
|
|
|
*,
|
|
|
|
|
|
provider_id: str,
|
|
|
|
|
|
) -> FailoverRulesConfig | None:
|
|
|
|
|
|
"""从 Provider.config 中安全提取故障转移规则配置。"""
|
|
|
|
|
|
raw = (provider_config or {}).get("failover_rules")
|
|
|
|
|
|
if raw is None:
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
if isinstance(raw, FailoverRulesConfig):
|
|
|
|
|
|
return raw
|
|
|
|
|
|
|
|
|
|
|
|
if not isinstance(raw, dict):
|
|
|
|
|
|
logger.warning(
|
|
|
|
|
|
"Provider {} 的 failover_rules 类型无效: {},已忽略",
|
|
|
|
|
|
provider_id,
|
|
|
|
|
|
type(raw).__name__,
|
|
|
|
|
|
)
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
return FailoverRulesConfig.model_validate(raw)
|
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
|
logger.warning(
|
|
|
|
|
|
"Provider {} 的 failover_rules 配置无效,已忽略: {}",
|
|
|
|
|
|
provider_id,
|
|
|
|
|
|
str(exc),
|
|
|
|
|
|
)
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
2025-12-10 20:52:44 +08:00
|
|
|
|
def _build_provider_summary(db: Session, provider: Provider) -> ProviderWithEndpointsSummary:
|
2026-03-03 22:04:40 +08:00
|
|
|
|
endpoints = (
|
|
|
|
|
|
db.query(ProviderEndpoint)
|
|
|
|
|
|
.options(
|
|
|
|
|
|
load_only(
|
|
|
|
|
|
ProviderEndpoint.id,
|
|
|
|
|
|
ProviderEndpoint.provider_id,
|
|
|
|
|
|
ProviderEndpoint.api_format,
|
|
|
|
|
|
ProviderEndpoint.is_active,
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
.filter(ProviderEndpoint.provider_id == provider.id)
|
|
|
|
|
|
.all()
|
|
|
|
|
|
)
|
2025-12-10 20:52:44 +08:00
|
|
|
|
|
2026-01-10 18:43:53 +08:00
|
|
|
|
key_stats = (
|
|
|
|
|
|
db.query(
|
2025-12-10 20:52:44 +08:00
|
|
|
|
func.count(ProviderAPIKey.id).label("total"),
|
|
|
|
|
|
func.sum(case((ProviderAPIKey.is_active == True, 1), else_=0)).label("active"),
|
2026-01-10 18:43:53 +08:00
|
|
|
|
)
|
|
|
|
|
|
.filter(ProviderAPIKey.provider_id == provider.id)
|
|
|
|
|
|
.first()
|
|
|
|
|
|
)
|
2026-03-03 22:04:40 +08:00
|
|
|
|
total_keys = int(key_stats.total or 0)
|
2026-01-10 18:43:53 +08:00
|
|
|
|
active_keys = int(key_stats.active or 0)
|
2025-12-10 20:52:44 +08:00
|
|
|
|
|
2026-02-01 17:28:00 +08:00
|
|
|
|
model_stats = (
|
|
|
|
|
|
db.query(
|
|
|
|
|
|
func.count(Model.id).label("total"),
|
|
|
|
|
|
func.sum(case((Model.is_active == True, 1), else_=0)).label("active"),
|
|
|
|
|
|
)
|
|
|
|
|
|
.filter(Model.provider_id == provider.id)
|
|
|
|
|
|
.first()
|
|
|
|
|
|
)
|
2026-03-03 22:04:40 +08:00
|
|
|
|
total_models = int(model_stats.total or 0)
|
2025-12-10 20:52:44 +08:00
|
|
|
|
active_models = int(model_stats.active or 0)
|
|
|
|
|
|
|
2026-02-06 21:52:22 +08:00
|
|
|
|
global_model_ids = [
|
|
|
|
|
|
row[0]
|
|
|
|
|
|
for row in db.query(Model.global_model_id)
|
|
|
|
|
|
.filter(
|
|
|
|
|
|
Model.provider_id == provider.id,
|
|
|
|
|
|
Model.is_active == True,
|
2026-03-03 22:04:40 +08:00
|
|
|
|
Model.global_model_id.isnot(None),
|
2026-02-06 21:52:22 +08:00
|
|
|
|
)
|
|
|
|
|
|
.distinct()
|
|
|
|
|
|
.all()
|
|
|
|
|
|
]
|
|
|
|
|
|
|
2026-03-03 22:04:40 +08:00
|
|
|
|
all_keys = (
|
|
|
|
|
|
db.query(ProviderAPIKey)
|
|
|
|
|
|
.options(
|
|
|
|
|
|
load_only(
|
|
|
|
|
|
ProviderAPIKey.id,
|
|
|
|
|
|
ProviderAPIKey.provider_id,
|
|
|
|
|
|
ProviderAPIKey.is_active,
|
|
|
|
|
|
ProviderAPIKey.api_formats,
|
|
|
|
|
|
ProviderAPIKey.health_by_format,
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
.filter(ProviderAPIKey.provider_id == provider.id)
|
|
|
|
|
|
.all()
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
return _compose_provider_summary(
|
|
|
|
|
|
provider=provider,
|
|
|
|
|
|
endpoints=endpoints,
|
|
|
|
|
|
all_keys=all_keys,
|
|
|
|
|
|
total_keys=total_keys,
|
|
|
|
|
|
active_keys=active_keys,
|
|
|
|
|
|
total_models=total_models,
|
|
|
|
|
|
active_models=active_models,
|
|
|
|
|
|
global_model_ids=global_model_ids,
|
|
|
|
|
|
)
|
2025-12-10 20:52:44 +08:00
|
|
|
|
|
2026-03-03 22:04:40 +08:00
|
|
|
|
|
|
|
|
|
|
def _compose_provider_summary(
|
|
|
|
|
|
*,
|
|
|
|
|
|
provider: Provider,
|
|
|
|
|
|
endpoints: list[ProviderEndpoint],
|
|
|
|
|
|
all_keys: list[ProviderAPIKey],
|
|
|
|
|
|
total_keys: int,
|
|
|
|
|
|
active_keys: int,
|
|
|
|
|
|
total_models: int,
|
|
|
|
|
|
active_models: int,
|
|
|
|
|
|
global_model_ids: list[Any],
|
|
|
|
|
|
) -> ProviderWithEndpointsSummary:
|
|
|
|
|
|
total_endpoints = len(endpoints)
|
|
|
|
|
|
active_endpoints = sum(1 for e in endpoints if e.is_active)
|
|
|
|
|
|
api_formats = [e.api_format for e in endpoints]
|
2025-12-10 20:52:44 +08:00
|
|
|
|
|
2026-01-10 18:43:53 +08:00
|
|
|
|
# 按 api_formats 分组 keys(通过 api_formats 关联)
|
|
|
|
|
|
format_to_endpoint_id: dict[str, str] = {e.api_format: e.id for e in endpoints}
|
|
|
|
|
|
keys_by_endpoint: dict[str, list[ProviderAPIKey]] = {e.id: [] for e in endpoints}
|
2025-12-10 20:52:44 +08:00
|
|
|
|
for key in all_keys:
|
2026-01-10 18:43:53 +08:00
|
|
|
|
formats = key.api_formats or []
|
|
|
|
|
|
for fmt in formats:
|
|
|
|
|
|
endpoint_id = format_to_endpoint_id.get(fmt)
|
|
|
|
|
|
if endpoint_id:
|
|
|
|
|
|
keys_by_endpoint[endpoint_id].append(key)
|
2025-12-10 20:52:44 +08:00
|
|
|
|
|
|
|
|
|
|
endpoint_health_map: dict[str, float] = {}
|
|
|
|
|
|
for endpoint in endpoints:
|
|
|
|
|
|
keys = keys_by_endpoint.get(endpoint.id, [])
|
|
|
|
|
|
if keys:
|
2026-01-10 18:43:53 +08:00
|
|
|
|
api_fmt = endpoint.api_format
|
2026-03-03 22:04:40 +08:00
|
|
|
|
health_scores: list[float] = []
|
2026-01-10 18:43:53 +08:00
|
|
|
|
for k in keys:
|
|
|
|
|
|
health_by_format = k.health_by_format or {}
|
|
|
|
|
|
if api_fmt in health_by_format:
|
|
|
|
|
|
score = health_by_format[api_fmt].get("health_score")
|
|
|
|
|
|
if score is not None:
|
|
|
|
|
|
health_scores.append(float(score))
|
|
|
|
|
|
else:
|
2026-03-03 22:04:40 +08:00
|
|
|
|
health_scores.append(1.0)
|
2025-12-10 20:52:44 +08:00
|
|
|
|
avg_health = sum(health_scores) / len(health_scores) if health_scores else 1.0
|
|
|
|
|
|
endpoint_health_map[endpoint.id] = avg_health
|
|
|
|
|
|
else:
|
|
|
|
|
|
endpoint_health_map[endpoint.id] = 1.0
|
|
|
|
|
|
|
|
|
|
|
|
all_health_scores = list(endpoint_health_map.values())
|
|
|
|
|
|
avg_health_score = sum(all_health_scores) / len(all_health_scores) if all_health_scores else 1.0
|
|
|
|
|
|
unhealthy_endpoints = sum(1 for score in all_health_scores if score < 0.5)
|
|
|
|
|
|
|
|
|
|
|
|
active_keys_by_endpoint: dict[str, int] = {}
|
|
|
|
|
|
for endpoint_id, keys in keys_by_endpoint.items():
|
|
|
|
|
|
active_keys_by_endpoint[endpoint_id] = sum(1 for k in keys if k.is_active)
|
|
|
|
|
|
|
|
|
|
|
|
endpoint_health_details = [
|
|
|
|
|
|
{
|
|
|
|
|
|
"api_format": e.api_format,
|
|
|
|
|
|
"health_score": endpoint_health_map.get(e.id, 1.0),
|
|
|
|
|
|
"is_active": e.is_active,
|
2026-01-21 01:10:35 +08:00
|
|
|
|
"total_keys": len(keys_by_endpoint.get(e.id, [])),
|
2025-12-10 20:52:44 +08:00
|
|
|
|
"active_keys": active_keys_by_endpoint.get(e.id, 0),
|
|
|
|
|
|
}
|
|
|
|
|
|
for e in endpoints
|
|
|
|
|
|
]
|
|
|
|
|
|
|
2026-02-27 13:54:46 +08:00
|
|
|
|
provider_config_raw = provider.config
|
|
|
|
|
|
provider_config = provider_config_raw if isinstance(provider_config_raw, dict) else {}
|
|
|
|
|
|
if provider_config_raw is not None and not isinstance(provider_config_raw, dict):
|
|
|
|
|
|
logger.warning(
|
|
|
|
|
|
"Provider {} 的 config 类型无效: {},按空配置处理",
|
|
|
|
|
|
provider.id,
|
|
|
|
|
|
type(provider_config_raw).__name__,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-01-17 19:50:35 +08:00
|
|
|
|
# 检查是否配置了 Provider Ops(余额监控等)
|
2026-02-27 13:54:46 +08:00
|
|
|
|
provider_ops_config = provider_config.get("provider_ops")
|
2026-01-19 17:18:03 +08:00
|
|
|
|
ops_configured = bool(provider_ops_config)
|
2026-02-01 17:28:00 +08:00
|
|
|
|
ops_architecture_id = (
|
|
|
|
|
|
provider_ops_config.get("architecture_id") if provider_ops_config else None
|
|
|
|
|
|
)
|
2026-02-27 13:54:46 +08:00
|
|
|
|
claude_code_advanced = _extract_claude_code_advanced_from_config(
|
|
|
|
|
|
provider_config,
|
|
|
|
|
|
provider_id=str(provider.id),
|
|
|
|
|
|
)
|
|
|
|
|
|
pool_advanced = _extract_pool_advanced_from_config(
|
|
|
|
|
|
provider_config,
|
|
|
|
|
|
provider_id=str(provider.id),
|
|
|
|
|
|
)
|
2026-03-01 00:21:16 +08:00
|
|
|
|
failover_rules = _extract_failover_rules_from_config(
|
|
|
|
|
|
provider_config,
|
|
|
|
|
|
provider_id=str(provider.id),
|
|
|
|
|
|
)
|
2026-01-17 19:50:35 +08:00
|
|
|
|
|
2025-12-10 20:52:44 +08:00
|
|
|
|
return ProviderWithEndpointsSummary(
|
|
|
|
|
|
id=provider.id,
|
|
|
|
|
|
name=provider.name,
|
2026-02-04 10:24:25 +08:00
|
|
|
|
provider_type=getattr(provider, "provider_type", None),
|
2025-12-10 20:52:44 +08:00
|
|
|
|
description=provider.description,
|
|
|
|
|
|
website=provider.website,
|
|
|
|
|
|
provider_priority=provider.provider_priority,
|
2026-01-28 15:53:33 +08:00
|
|
|
|
keep_priority_on_conversion=provider.keep_priority_on_conversion,
|
2026-02-02 03:16:52 +08:00
|
|
|
|
enable_format_conversion=provider.enable_format_conversion,
|
2025-12-10 20:52:44 +08:00
|
|
|
|
is_active=provider.is_active,
|
|
|
|
|
|
billing_type=provider.billing_type.value if provider.billing_type else None,
|
|
|
|
|
|
monthly_quota_usd=provider.monthly_quota_usd,
|
|
|
|
|
|
monthly_used_usd=provider.monthly_used_usd,
|
|
|
|
|
|
quota_reset_day=provider.quota_reset_day,
|
|
|
|
|
|
quota_last_reset_at=provider.quota_last_reset_at,
|
|
|
|
|
|
quota_expires_at=provider.quota_expires_at,
|
2026-01-10 18:43:53 +08:00
|
|
|
|
max_retries=provider.max_retries,
|
|
|
|
|
|
proxy=provider.proxy,
|
2026-01-28 00:04:47 +08:00
|
|
|
|
stream_first_byte_timeout=provider.stream_first_byte_timeout,
|
|
|
|
|
|
request_timeout=provider.request_timeout,
|
2026-02-27 13:54:46 +08:00
|
|
|
|
claude_code_advanced=claude_code_advanced,
|
|
|
|
|
|
pool_advanced=pool_advanced,
|
2026-03-01 00:21:16 +08:00
|
|
|
|
failover_rules=failover_rules,
|
2025-12-10 20:52:44 +08:00
|
|
|
|
total_endpoints=total_endpoints,
|
|
|
|
|
|
active_endpoints=active_endpoints,
|
|
|
|
|
|
total_keys=total_keys,
|
|
|
|
|
|
active_keys=active_keys,
|
|
|
|
|
|
total_models=total_models,
|
|
|
|
|
|
active_models=active_models,
|
2026-02-06 21:52:22 +08:00
|
|
|
|
global_model_ids=global_model_ids,
|
2025-12-10 20:52:44 +08:00
|
|
|
|
avg_health_score=avg_health_score,
|
|
|
|
|
|
unhealthy_endpoints=unhealthy_endpoints,
|
|
|
|
|
|
api_formats=api_formats,
|
|
|
|
|
|
endpoint_health_details=endpoint_health_details,
|
2026-01-17 19:50:35 +08:00
|
|
|
|
ops_configured=ops_configured,
|
2026-01-19 17:18:03 +08:00
|
|
|
|
ops_architecture_id=ops_architecture_id,
|
2025-12-10 20:52:44 +08:00
|
|
|
|
created_at=provider.created_at,
|
|
|
|
|
|
updated_at=provider.updated_at,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-03-03 22:04:40 +08:00
|
|
|
|
def _build_provider_summaries_batch(
|
|
|
|
|
|
db: Session, providers: list[Provider]
|
|
|
|
|
|
) -> list[ProviderWithEndpointsSummary]:
|
|
|
|
|
|
if not providers:
|
|
|
|
|
|
return []
|
|
|
|
|
|
|
|
|
|
|
|
provider_ids = [provider.id for provider in providers]
|
|
|
|
|
|
|
|
|
|
|
|
endpoint_rows = (
|
|
|
|
|
|
db.query(ProviderEndpoint)
|
|
|
|
|
|
.options(
|
|
|
|
|
|
load_only(
|
|
|
|
|
|
ProviderEndpoint.id,
|
|
|
|
|
|
ProviderEndpoint.provider_id,
|
|
|
|
|
|
ProviderEndpoint.api_format,
|
|
|
|
|
|
ProviderEndpoint.is_active,
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
.filter(ProviderEndpoint.provider_id.in_(provider_ids))
|
|
|
|
|
|
.all()
|
|
|
|
|
|
)
|
|
|
|
|
|
endpoints_by_provider: dict[str, list[ProviderEndpoint]] = {}
|
|
|
|
|
|
for endpoint in endpoint_rows:
|
|
|
|
|
|
endpoints_by_provider.setdefault(str(endpoint.provider_id), []).append(endpoint)
|
|
|
|
|
|
|
|
|
|
|
|
key_rows = (
|
|
|
|
|
|
db.query(ProviderAPIKey)
|
|
|
|
|
|
.options(
|
|
|
|
|
|
load_only(
|
|
|
|
|
|
ProviderAPIKey.id,
|
|
|
|
|
|
ProviderAPIKey.provider_id,
|
|
|
|
|
|
ProviderAPIKey.is_active,
|
|
|
|
|
|
ProviderAPIKey.api_formats,
|
|
|
|
|
|
ProviderAPIKey.health_by_format,
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
.filter(ProviderAPIKey.provider_id.in_(provider_ids))
|
|
|
|
|
|
.all()
|
|
|
|
|
|
)
|
|
|
|
|
|
keys_by_provider: dict[str, list[ProviderAPIKey]] = {}
|
|
|
|
|
|
for key in key_rows:
|
|
|
|
|
|
keys_by_provider.setdefault(str(key.provider_id), []).append(key)
|
|
|
|
|
|
|
|
|
|
|
|
key_stats_rows = (
|
|
|
|
|
|
db.query(
|
|
|
|
|
|
ProviderAPIKey.provider_id.label("provider_id"),
|
|
|
|
|
|
func.count(ProviderAPIKey.id).label("total"),
|
|
|
|
|
|
func.sum(case((ProviderAPIKey.is_active == True, 1), else_=0)).label("active"),
|
|
|
|
|
|
)
|
|
|
|
|
|
.filter(ProviderAPIKey.provider_id.in_(provider_ids))
|
|
|
|
|
|
.group_by(ProviderAPIKey.provider_id)
|
|
|
|
|
|
.all()
|
|
|
|
|
|
)
|
|
|
|
|
|
key_stats_by_provider: dict[str, dict[str, int]] = {
|
|
|
|
|
|
str(row.provider_id): {
|
|
|
|
|
|
"total": int(row.total or 0),
|
|
|
|
|
|
"active": int(row.active or 0),
|
|
|
|
|
|
}
|
|
|
|
|
|
for row in key_stats_rows
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
model_stats_rows = (
|
|
|
|
|
|
db.query(
|
|
|
|
|
|
Model.provider_id.label("provider_id"),
|
|
|
|
|
|
func.count(Model.id).label("total"),
|
|
|
|
|
|
func.sum(case((Model.is_active == True, 1), else_=0)).label("active"),
|
|
|
|
|
|
)
|
|
|
|
|
|
.filter(Model.provider_id.in_(provider_ids))
|
|
|
|
|
|
.group_by(Model.provider_id)
|
|
|
|
|
|
.all()
|
|
|
|
|
|
)
|
|
|
|
|
|
model_stats_by_provider: dict[str, dict[str, int]] = {
|
|
|
|
|
|
str(row.provider_id): {
|
|
|
|
|
|
"total": int(row.total or 0),
|
|
|
|
|
|
"active": int(row.active or 0),
|
|
|
|
|
|
}
|
|
|
|
|
|
for row in model_stats_rows
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
global_model_rows = (
|
|
|
|
|
|
db.query(Model.provider_id, Model.global_model_id)
|
|
|
|
|
|
.filter(
|
|
|
|
|
|
Model.provider_id.in_(provider_ids),
|
|
|
|
|
|
Model.is_active == True,
|
|
|
|
|
|
Model.global_model_id.isnot(None),
|
|
|
|
|
|
)
|
|
|
|
|
|
.distinct()
|
|
|
|
|
|
.all()
|
|
|
|
|
|
)
|
|
|
|
|
|
global_model_ids_by_provider: dict[str, list[Any]] = {}
|
|
|
|
|
|
for provider_id, global_model_id in global_model_rows:
|
|
|
|
|
|
global_model_ids_by_provider.setdefault(str(provider_id), []).append(global_model_id)
|
|
|
|
|
|
|
|
|
|
|
|
summaries: list[ProviderWithEndpointsSummary] = []
|
|
|
|
|
|
for provider in providers:
|
|
|
|
|
|
pid = str(provider.id)
|
|
|
|
|
|
key_stats = key_stats_by_provider.get(pid, {"total": 0, "active": 0})
|
|
|
|
|
|
model_stats = model_stats_by_provider.get(pid, {"total": 0, "active": 0})
|
|
|
|
|
|
summaries.append(
|
|
|
|
|
|
_compose_provider_summary(
|
|
|
|
|
|
provider=provider,
|
|
|
|
|
|
endpoints=endpoints_by_provider.get(pid, []),
|
|
|
|
|
|
all_keys=keys_by_provider.get(pid, []),
|
|
|
|
|
|
total_keys=key_stats["total"],
|
|
|
|
|
|
active_keys=key_stats["active"],
|
|
|
|
|
|
total_models=model_stats["total"],
|
|
|
|
|
|
active_models=model_stats["active"],
|
|
|
|
|
|
global_model_ids=global_model_ids_by_provider.get(pid, []),
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
return summaries
|
|
|
|
|
|
|
|
|
|
|
|
|
2025-12-10 20:52:44 +08:00
|
|
|
|
# -------- Adapters --------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass
|
|
|
|
|
|
class AdminProviderHealthMonitorAdapter(AdminApiAdapter):
|
|
|
|
|
|
provider_id: str
|
|
|
|
|
|
lookback_hours: int
|
|
|
|
|
|
per_endpoint_limit: int
|
|
|
|
|
|
|
2026-03-03 22:04:40 +08:00
|
|
|
|
@cache_result(
|
|
|
|
|
|
key_prefix="admin:providers:health-monitor",
|
|
|
|
|
|
ttl=CacheTTL.ADMIN_USAGE_RECORDS,
|
|
|
|
|
|
user_specific=False,
|
|
|
|
|
|
vary_by=["provider_id", "lookback_hours", "per_endpoint_limit"],
|
|
|
|
|
|
)
|
2026-01-30 14:30:57 +08:00
|
|
|
|
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
2025-12-10 20:52:44 +08:00
|
|
|
|
db = context.db
|
|
|
|
|
|
provider = db.query(Provider).filter(Provider.id == self.provider_id).first()
|
|
|
|
|
|
if not provider:
|
|
|
|
|
|
raise NotFoundException(f"Provider {self.provider_id} 不存在")
|
|
|
|
|
|
|
|
|
|
|
|
endpoints = (
|
|
|
|
|
|
db.query(ProviderEndpoint)
|
2026-03-03 22:04:40 +08:00
|
|
|
|
.options(
|
|
|
|
|
|
load_only(
|
|
|
|
|
|
ProviderEndpoint.id,
|
|
|
|
|
|
ProviderEndpoint.provider_id,
|
|
|
|
|
|
ProviderEndpoint.api_format,
|
|
|
|
|
|
ProviderEndpoint.is_active,
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
2025-12-10 20:52:44 +08:00
|
|
|
|
.filter(ProviderEndpoint.provider_id == self.provider_id)
|
|
|
|
|
|
.all()
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
now = datetime.now(timezone.utc)
|
|
|
|
|
|
since = now - timedelta(hours=self.lookback_hours)
|
|
|
|
|
|
|
2026-03-03 22:04:40 +08:00
|
|
|
|
endpoint_ids = [str(endpoint.id) for endpoint in endpoints]
|
2025-12-10 20:52:44 +08:00
|
|
|
|
if not endpoint_ids:
|
|
|
|
|
|
response = ProviderEndpointHealthMonitorResponse(
|
|
|
|
|
|
provider_id=provider.id,
|
2026-01-10 18:43:53 +08:00
|
|
|
|
provider_name=provider.name,
|
2025-12-10 20:52:44 +08:00
|
|
|
|
generated_at=now,
|
|
|
|
|
|
endpoints=[],
|
|
|
|
|
|
)
|
|
|
|
|
|
context.add_audit_metadata(
|
|
|
|
|
|
action="provider_health_monitor",
|
|
|
|
|
|
provider_id=self.provider_id,
|
|
|
|
|
|
endpoint_count=0,
|
|
|
|
|
|
lookback_hours=self.lookback_hours,
|
|
|
|
|
|
)
|
2026-03-03 22:04:40 +08:00
|
|
|
|
return response.model_dump()
|
2025-12-10 20:52:44 +08:00
|
|
|
|
|
2026-03-03 22:04:40 +08:00
|
|
|
|
ranked_attempts_subq = (
|
2025-12-10 20:52:44 +08:00
|
|
|
|
db.query(RequestCandidate)
|
2026-03-03 22:04:40 +08:00
|
|
|
|
.with_entities(
|
|
|
|
|
|
RequestCandidate.endpoint_id.label("endpoint_id"),
|
|
|
|
|
|
RequestCandidate.status.label("status"),
|
|
|
|
|
|
RequestCandidate.status_code.label("status_code"),
|
|
|
|
|
|
RequestCandidate.latency_ms.label("latency_ms"),
|
|
|
|
|
|
RequestCandidate.error_type.label("error_type"),
|
|
|
|
|
|
RequestCandidate.error_message.label("error_message"),
|
|
|
|
|
|
func.coalesce(
|
|
|
|
|
|
RequestCandidate.finished_at,
|
|
|
|
|
|
RequestCandidate.started_at,
|
|
|
|
|
|
RequestCandidate.created_at,
|
|
|
|
|
|
).label("event_timestamp"),
|
|
|
|
|
|
func.row_number()
|
|
|
|
|
|
.over(
|
|
|
|
|
|
partition_by=RequestCandidate.endpoint_id,
|
|
|
|
|
|
order_by=RequestCandidate.created_at.desc(),
|
|
|
|
|
|
)
|
|
|
|
|
|
.label("rn"),
|
|
|
|
|
|
)
|
2025-12-10 20:52:44 +08:00
|
|
|
|
.filter(
|
|
|
|
|
|
RequestCandidate.endpoint_id.in_(endpoint_ids),
|
|
|
|
|
|
RequestCandidate.created_at >= since,
|
|
|
|
|
|
)
|
2026-03-03 22:04:40 +08:00
|
|
|
|
.subquery()
|
|
|
|
|
|
)
|
|
|
|
|
|
attempt_rows = (
|
|
|
|
|
|
db.query(ranked_attempts_subq)
|
|
|
|
|
|
.filter(ranked_attempts_subq.c.rn <= self.per_endpoint_limit)
|
|
|
|
|
|
.order_by(
|
|
|
|
|
|
ranked_attempts_subq.c.endpoint_id.asc(),
|
|
|
|
|
|
ranked_attempts_subq.c.event_timestamp.asc(),
|
|
|
|
|
|
)
|
|
|
|
|
|
.all()
|
2025-12-10 20:52:44 +08:00
|
|
|
|
)
|
|
|
|
|
|
|
2026-03-03 22:04:40 +08:00
|
|
|
|
events_by_endpoint: dict[str, list[EndpointHealthEvent]] = {eid: [] for eid in endpoint_ids}
|
|
|
|
|
|
for row in attempt_rows:
|
|
|
|
|
|
endpoint_id = str(row.endpoint_id) if row.endpoint_id is not None else ""
|
|
|
|
|
|
if not endpoint_id or endpoint_id not in events_by_endpoint:
|
2025-12-10 20:52:44 +08:00
|
|
|
|
continue
|
2026-03-03 22:04:40 +08:00
|
|
|
|
events_by_endpoint[endpoint_id].append(
|
|
|
|
|
|
EndpointHealthEvent(
|
|
|
|
|
|
timestamp=row.event_timestamp,
|
|
|
|
|
|
status=row.status,
|
|
|
|
|
|
status_code=row.status_code,
|
|
|
|
|
|
latency_ms=row.latency_ms,
|
|
|
|
|
|
error_type=row.error_type,
|
|
|
|
|
|
error_message=row.error_message,
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
2025-12-10 20:52:44 +08:00
|
|
|
|
|
2026-01-30 03:10:21 +08:00
|
|
|
|
endpoint_monitors: list[EndpointHealthMonitor] = []
|
2025-12-10 20:52:44 +08:00
|
|
|
|
for endpoint in endpoints:
|
2026-03-03 22:04:40 +08:00
|
|
|
|
endpoint_id = str(endpoint.id)
|
|
|
|
|
|
events = events_by_endpoint.get(endpoint_id, [])
|
2025-12-10 20:52:44 +08:00
|
|
|
|
|
|
|
|
|
|
success_count = sum(1 for event in events if event.status == "success")
|
|
|
|
|
|
failed_count = sum(1 for event in events if event.status == "failed")
|
|
|
|
|
|
skipped_count = sum(1 for event in events if event.status == "skipped")
|
|
|
|
|
|
total_attempts = len(events)
|
|
|
|
|
|
success_rate = success_count / total_attempts if total_attempts else 1.0
|
|
|
|
|
|
last_event_at = events[-1].timestamp if events else None
|
|
|
|
|
|
|
|
|
|
|
|
endpoint_monitors.append(
|
|
|
|
|
|
EndpointHealthMonitor(
|
|
|
|
|
|
endpoint_id=endpoint.id,
|
|
|
|
|
|
api_format=endpoint.api_format,
|
|
|
|
|
|
is_active=endpoint.is_active,
|
|
|
|
|
|
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,
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
response = ProviderEndpointHealthMonitorResponse(
|
|
|
|
|
|
provider_id=provider.id,
|
2026-01-10 18:43:53 +08:00
|
|
|
|
provider_name=provider.name,
|
2025-12-10 20:52:44 +08:00
|
|
|
|
generated_at=now,
|
|
|
|
|
|
endpoints=endpoint_monitors,
|
|
|
|
|
|
)
|
|
|
|
|
|
context.add_audit_metadata(
|
|
|
|
|
|
action="provider_health_monitor",
|
|
|
|
|
|
provider_id=self.provider_id,
|
|
|
|
|
|
endpoint_count=len(endpoint_monitors),
|
|
|
|
|
|
lookback_hours=self.lookback_hours,
|
|
|
|
|
|
per_endpoint_limit=self.per_endpoint_limit,
|
|
|
|
|
|
)
|
2026-03-03 22:04:40 +08:00
|
|
|
|
return response.model_dump()
|
2025-12-10 20:52:44 +08:00
|
|
|
|
|
|
|
|
|
|
|
2026-03-09 12:45:54 +08:00
|
|
|
|
@dataclass
|
2025-12-10 20:52:44 +08:00
|
|
|
|
class AdminProviderSummaryAdapter(AdminApiAdapter):
|
2026-03-09 12:45:54 +08:00
|
|
|
|
page: int = 1
|
|
|
|
|
|
page_size: int = 20
|
|
|
|
|
|
search: str = ""
|
|
|
|
|
|
status: str = "all"
|
|
|
|
|
|
api_format: str = "all"
|
|
|
|
|
|
model_id: str = "all"
|
|
|
|
|
|
|
2026-01-30 14:30:57 +08:00
|
|
|
|
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
2025-12-10 20:52:44 +08:00
|
|
|
|
db = context.db
|
2026-03-09 12:45:54 +08:00
|
|
|
|
|
|
|
|
|
|
query = db.query(Provider)
|
|
|
|
|
|
|
|
|
|
|
|
# 搜索筛选
|
|
|
|
|
|
if self.search.strip():
|
|
|
|
|
|
keywords = self.search.strip().lower().split()
|
|
|
|
|
|
for kw in keywords:
|
|
|
|
|
|
query = query.filter(func.lower(Provider.name).contains(kw))
|
|
|
|
|
|
|
|
|
|
|
|
# 状态筛选
|
|
|
|
|
|
if self.status == "active":
|
|
|
|
|
|
query = query.filter(Provider.is_active == True)
|
|
|
|
|
|
elif self.status == "inactive":
|
|
|
|
|
|
query = query.filter(Provider.is_active == False)
|
|
|
|
|
|
|
|
|
|
|
|
# API 格式筛选
|
|
|
|
|
|
if self.api_format != "all":
|
|
|
|
|
|
query = query.filter(
|
|
|
|
|
|
Provider.id.in_(
|
|
|
|
|
|
db.query(ProviderEndpoint.provider_id)
|
|
|
|
|
|
.filter(ProviderEndpoint.api_format == self.api_format)
|
|
|
|
|
|
.distinct()
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
# 全局模型 ID 筛选
|
|
|
|
|
|
if self.model_id != "all":
|
|
|
|
|
|
query = query.filter(
|
|
|
|
|
|
Provider.id.in_(
|
|
|
|
|
|
db.query(Model.provider_id)
|
|
|
|
|
|
.filter(
|
|
|
|
|
|
Model.global_model_id == self.model_id,
|
|
|
|
|
|
Model.is_active == True,
|
|
|
|
|
|
)
|
|
|
|
|
|
.distinct()
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
total = query.count()
|
|
|
|
|
|
|
2025-12-10 20:52:44 +08:00
|
|
|
|
providers = (
|
2026-03-15 23:13:11 +08:00
|
|
|
|
query.order_by(*_provider_summary_ordering(Provider))
|
2026-03-09 12:45:54 +08:00
|
|
|
|
.offset((self.page - 1) * self.page_size)
|
|
|
|
|
|
.limit(self.page_size)
|
2025-12-10 20:52:44 +08:00
|
|
|
|
.all()
|
|
|
|
|
|
)
|
2026-03-09 12:45:54 +08:00
|
|
|
|
|
|
|
|
|
|
items = _build_provider_summaries_batch(db, providers)
|
|
|
|
|
|
return ProviderSummaryPageResponse(
|
|
|
|
|
|
total=total,
|
|
|
|
|
|
page=self.page,
|
|
|
|
|
|
page_size=self.page_size,
|
|
|
|
|
|
items=items,
|
|
|
|
|
|
).model_dump()
|
2026-03-03 22:04:40 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass
|
|
|
|
|
|
class AdminProviderDetailAdapter(AdminApiAdapter):
|
|
|
|
|
|
provider_id: str
|
|
|
|
|
|
|
|
|
|
|
|
@cache_result(
|
|
|
|
|
|
key_prefix="admin:providers:summary:detail",
|
|
|
|
|
|
ttl=CacheTTL.ADMIN_USAGE_RECORDS,
|
|
|
|
|
|
user_specific=False,
|
|
|
|
|
|
vary_by=["provider_id"],
|
|
|
|
|
|
)
|
|
|
|
|
|
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
|
|
|
|
|
db = context.db
|
|
|
|
|
|
provider = db.query(Provider).filter(Provider.id == self.provider_id).first()
|
|
|
|
|
|
if not provider:
|
|
|
|
|
|
raise NotFoundException(f"Provider {self.provider_id} not found")
|
|
|
|
|
|
return _build_provider_summary(db, provider).model_dump()
|
2025-12-10 20:52:44 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass
|
|
|
|
|
|
class AdminUpdateProviderSettingsAdapter(AdminApiAdapter):
|
|
|
|
|
|
provider_id: str
|
|
|
|
|
|
update_data: ProviderUpdateRequest
|
|
|
|
|
|
|
2026-01-30 14:30:57 +08:00
|
|
|
|
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
2025-12-10 20:52:44 +08:00
|
|
|
|
db = context.db
|
|
|
|
|
|
provider = db.query(Provider).filter(Provider.id == self.provider_id).first()
|
|
|
|
|
|
if not provider:
|
|
|
|
|
|
raise NotFoundException("Provider not found", "provider")
|
|
|
|
|
|
|
|
|
|
|
|
update_dict = self.update_data.model_dump(exclude_unset=True)
|
2026-02-27 13:54:46 +08:00
|
|
|
|
if "claude_code_advanced" in update_dict:
|
|
|
|
|
|
claude_advanced = update_dict.pop("claude_code_advanced")
|
|
|
|
|
|
provider_type = str(getattr(provider, "provider_type", "") or "").strip().lower()
|
|
|
|
|
|
if claude_advanced is not None and provider_type != "claude_code":
|
|
|
|
|
|
raise InvalidRequestException(
|
|
|
|
|
|
"claude_code_advanced 仅适用于 provider_type=claude_code"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
provider_config = dict(provider.config or {})
|
|
|
|
|
|
if claude_advanced is None:
|
|
|
|
|
|
provider_config.pop("claude_code_advanced", None)
|
|
|
|
|
|
else:
|
|
|
|
|
|
provider_config["claude_code_advanced"] = dict(claude_advanced)
|
|
|
|
|
|
update_dict["config"] = provider_config or None
|
|
|
|
|
|
|
|
|
|
|
|
if "pool_advanced" in update_dict:
|
|
|
|
|
|
pool_advanced = update_dict.pop("pool_advanced")
|
|
|
|
|
|
provider_config = dict(update_dict.get("config") or provider.config or {})
|
|
|
|
|
|
if pool_advanced is None:
|
|
|
|
|
|
provider_config.pop("pool_advanced", None)
|
|
|
|
|
|
else:
|
|
|
|
|
|
provider_config["pool_advanced"] = dict(pool_advanced)
|
|
|
|
|
|
update_dict["config"] = provider_config or None
|
|
|
|
|
|
|
2025-12-10 20:52:44 +08:00
|
|
|
|
if "billing_type" in update_dict and update_dict["billing_type"] is not None:
|
|
|
|
|
|
update_dict["billing_type"] = ProviderBillingType(update_dict["billing_type"])
|
|
|
|
|
|
|
|
|
|
|
|
for key, value in update_dict.items():
|
|
|
|
|
|
setattr(provider, key, value)
|
|
|
|
|
|
|
2026-02-06 23:01:56 +08:00
|
|
|
|
provider.updated_at = datetime.now(timezone.utc)
|
2025-12-10 20:52:44 +08:00
|
|
|
|
db.commit()
|
|
|
|
|
|
db.refresh(provider)
|
|
|
|
|
|
|
|
|
|
|
|
admin_name = context.user.username if context.user else "admin"
|
|
|
|
|
|
logger.info(f"Provider {provider.name} updated by {admin_name}: {update_dict}")
|
|
|
|
|
|
|
2026-02-06 23:01:56 +08:00
|
|
|
|
# 缓存失效
|
|
|
|
|
|
affects_model_visibility = {"is_active", "enable_format_conversion"} & update_dict.keys()
|
|
|
|
|
|
if affects_model_visibility:
|
|
|
|
|
|
await invalidate_models_list_cache()
|
|
|
|
|
|
if "is_active" in update_dict:
|
|
|
|
|
|
await ModelCacheService.invalidate_all_resolve_cache()
|
|
|
|
|
|
|
|
|
|
|
|
if "billing_type" in update_dict:
|
|
|
|
|
|
await ProviderCacheService.invalidate_provider_cache(provider.id)
|
|
|
|
|
|
|
2025-12-10 20:52:44 +08:00
|
|
|
|
return _build_provider_summary(db, provider)
|