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:
22
_deprecated_py_src/services/orchestration/__init__.py
Normal file
22
_deprecated_py_src/services/orchestration/__init__.py
Normal file
@@ -0,0 +1,22 @@
|
||||
"""
|
||||
Orchestration 模块
|
||||
|
||||
提供请求编排相关的组件:
|
||||
- CandidateResolver: 候选解析器,负责获取和排序可用的 Provider 组合
|
||||
- RequestDispatcher: 请求分发器,负责执行单个候选请求
|
||||
- ErrorClassifier: 错误分类器,负责错误分类(纯逻辑,无副作用)
|
||||
- ErrorHandlerService: 错误处理服务,负责错误后的副作用(缓存失效、健康记录等)
|
||||
"""
|
||||
|
||||
from .candidate_resolver import CandidateResolver
|
||||
from .error_classifier import ErrorAction, ErrorClassifier
|
||||
from .error_handler import ErrorHandlerService
|
||||
from .request_dispatcher import RequestDispatcher
|
||||
|
||||
__all__ = [
|
||||
"CandidateResolver",
|
||||
"RequestDispatcher",
|
||||
"ErrorClassifier",
|
||||
"ErrorHandlerService",
|
||||
"ErrorAction",
|
||||
]
|
||||
453
_deprecated_py_src/services/orchestration/candidate_resolver.py
Normal file
453
_deprecated_py_src/services/orchestration/candidate_resolver.py
Normal file
@@ -0,0 +1,453 @@
|
||||
"""
|
||||
候选解析器
|
||||
|
||||
负责获取和排序可用的 Provider/Endpoint/Key 组合
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from src.core.exceptions import ProviderNotAvailableException
|
||||
from src.core.logger import logger
|
||||
from src.models.database import ApiKey
|
||||
from src.services.provider.format import normalize_endpoint_signature
|
||||
from src.services.scheduling.aware_scheduler import CacheAwareScheduler
|
||||
from src.services.scheduling.schemas import PoolCandidate, ProviderCandidate
|
||||
|
||||
|
||||
class CandidateResolver:
|
||||
"""
|
||||
候选解析器 - 负责获取和排序可用的 Provider 组合
|
||||
|
||||
职责:
|
||||
1. 从 CacheAwareScheduler 获取所有可用候选
|
||||
2. 创建候选记录(用于追踪)
|
||||
3. 提供候选的迭代和过滤功能
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
db: Session,
|
||||
cache_scheduler: CacheAwareScheduler,
|
||||
) -> None:
|
||||
"""
|
||||
初始化候选解析器
|
||||
|
||||
Args:
|
||||
db: 数据库会话
|
||||
cache_scheduler: 缓存感知调度器
|
||||
"""
|
||||
self.db = db
|
||||
self.cache_scheduler = cache_scheduler
|
||||
|
||||
async def fetch_candidates(
|
||||
self,
|
||||
api_format: str,
|
||||
model_name: str,
|
||||
affinity_key: str,
|
||||
user_api_key: ApiKey | None = None,
|
||||
request_id: str | None = None,
|
||||
is_stream: bool = False,
|
||||
capability_requirements: dict[str, bool] | None = None,
|
||||
preferred_key_ids: list[str] | None = None,
|
||||
request_body: dict | None = None,
|
||||
) -> tuple[list[ProviderCandidate], str]:
|
||||
"""
|
||||
获取所有可用候选
|
||||
|
||||
Args:
|
||||
api_format: API 格式
|
||||
model_name: 模型名称
|
||||
affinity_key: 亲和性标识符(通常为API Key ID,用于缓存亲和性)
|
||||
user_api_key: 用户 API Key(用于 allowed_providers/allowed_api_formats 过滤)
|
||||
request_id: 请求 ID(用于日志)
|
||||
is_stream: 是否是流式请求,如果为 True 则过滤不支持流式的 Provider
|
||||
capability_requirements: 能力需求(用于过滤不满足能力要求的 Key)
|
||||
preferred_key_ids: 优先使用的 Provider Key ID 列表(匹配则置顶)
|
||||
|
||||
Returns:
|
||||
(所有候选组合的列表, global_model_id)
|
||||
|
||||
Raises:
|
||||
ProviderNotAvailableException: 没有找到任何可用候选时
|
||||
"""
|
||||
all_candidates: list[ProviderCandidate] = []
|
||||
provider_offset = 0
|
||||
provider_batch_size = 20
|
||||
global_model_id: str | None = None
|
||||
api_format_norm = normalize_endpoint_signature(api_format)
|
||||
|
||||
logger.debug(
|
||||
"[CandidateResolver] fetch_candidates starting: model={}, api_format={}",
|
||||
model_name,
|
||||
api_format_norm,
|
||||
)
|
||||
|
||||
while True:
|
||||
candidates, resolved_global_model_id, provider_batch_count = (
|
||||
await self.cache_scheduler.list_all_candidates(
|
||||
db=self.db,
|
||||
api_format=api_format_norm,
|
||||
model_name=model_name,
|
||||
affinity_key=affinity_key,
|
||||
user_api_key=user_api_key,
|
||||
provider_offset=provider_offset,
|
||||
provider_limit=provider_batch_size,
|
||||
is_stream=is_stream,
|
||||
capability_requirements=capability_requirements,
|
||||
request_body=request_body,
|
||||
)
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
"[CandidateResolver] list_all_candidates batch: offset={}, providers={}, returned={} candidates",
|
||||
provider_offset,
|
||||
provider_batch_count,
|
||||
len(candidates),
|
||||
)
|
||||
|
||||
if resolved_global_model_id and global_model_id is None:
|
||||
global_model_id = resolved_global_model_id
|
||||
|
||||
if provider_batch_count == 0:
|
||||
break
|
||||
|
||||
all_candidates.extend(candidates)
|
||||
provider_offset += provider_batch_size
|
||||
|
||||
if provider_batch_count < provider_batch_size:
|
||||
break
|
||||
|
||||
logger.debug(
|
||||
"[CandidateResolver] fetch_candidates completed: total={} candidates",
|
||||
len(all_candidates),
|
||||
)
|
||||
|
||||
if not all_candidates:
|
||||
logger.error(f" [{request_id}] 没有找到任何可用的 Provider/Endpoint/Key 组合")
|
||||
request_type = "流式" if is_stream else "非流式"
|
||||
raise ProviderNotAvailableException(
|
||||
f"没有可用的 Provider 支持模型 {model_name} 的{request_type}请求"
|
||||
)
|
||||
|
||||
logger.debug(f" [{request_id}] 获取到 {len(all_candidates)} 个候选组合")
|
||||
|
||||
# Provider 分页会导致候选在全局维度上排序失真(尤其是 global_key / 降级分组 / cache_affinity)。
|
||||
# 这里在汇总后再次应用全局排序规则,保证遍历顺序符合当前调度配置。
|
||||
try:
|
||||
all_candidates = await self.cache_scheduler.reorder_candidates(
|
||||
candidates=all_candidates,
|
||||
db=self.db,
|
||||
affinity_key=affinity_key,
|
||||
api_format=api_format_norm,
|
||||
global_model_id=global_model_id,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"[CandidateResolver] global reorder failed, keep paged order: {}",
|
||||
exc,
|
||||
)
|
||||
|
||||
if preferred_key_ids:
|
||||
preferred_set = {str(kid) for kid in preferred_key_ids if kid}
|
||||
if preferred_set:
|
||||
|
||||
def _is_preferred_candidate(c: ProviderCandidate) -> bool:
|
||||
if c.key and str(c.key.id) in preferred_set:
|
||||
return True
|
||||
if isinstance(c, PoolCandidate):
|
||||
return any(str(pk.id) in preferred_set for pk in (c.pool_keys or []))
|
||||
return False
|
||||
|
||||
preferred_candidates = [c for c in all_candidates if _is_preferred_candidate(c)]
|
||||
other_candidates = [c for c in all_candidates if not _is_preferred_candidate(c)]
|
||||
if preferred_candidates:
|
||||
matched_key_ids: list[str] = []
|
||||
for candidate in preferred_candidates:
|
||||
if isinstance(candidate, PoolCandidate):
|
||||
matched_key_ids.extend(
|
||||
str(pk.id)
|
||||
for pk in (candidate.pool_keys or [])
|
||||
if str(pk.id) in preferred_set
|
||||
)
|
||||
elif candidate.key:
|
||||
matched_key_ids.append(str(candidate.key.id))
|
||||
logger.debug(
|
||||
f" [{request_id}] 优先候选命中: {len(preferred_candidates)} 个 "
|
||||
f"(key_ids={matched_key_ids[:3]}{'...' if len(matched_key_ids) > 3 else ''})"
|
||||
)
|
||||
else:
|
||||
logger.debug(
|
||||
f" [{request_id}] 优先候选未命中: 请求的 key_ids={list(preferred_set)[:3]} "
|
||||
"不在可用候选中,将使用普通优先级"
|
||||
)
|
||||
all_candidates = preferred_candidates + other_candidates
|
||||
|
||||
# 如果没有解析到 global_model_id,使用原始 model_name 作为后备
|
||||
return all_candidates, global_model_id or model_name
|
||||
|
||||
def create_candidate_records(
|
||||
self,
|
||||
all_candidates: list[ProviderCandidate],
|
||||
request_id: str | None,
|
||||
user_id: str | None,
|
||||
user_api_key: ApiKey | None,
|
||||
required_capabilities: dict[str, bool] | None = None,
|
||||
*,
|
||||
expand_retries: bool = True,
|
||||
) -> dict[tuple[int, int], str]:
|
||||
"""
|
||||
为所有候选预先创建 available 状态记录(批量插入优化)
|
||||
|
||||
Args:
|
||||
all_candidates: 所有候选组合
|
||||
request_id: 请求 ID
|
||||
user_id: 用户 ID
|
||||
user_api_key: 用户 API Key 对象
|
||||
required_capabilities: 请求需要的能力标签
|
||||
|
||||
Returns:
|
||||
candidate_record_map: {(candidate_index, retry_index): candidate_record_id}
|
||||
"""
|
||||
from src.models.database import RequestCandidate
|
||||
|
||||
candidate_records_to_insert: list[dict[str, Any]] = []
|
||||
candidate_record_map: dict[tuple[int, int], str] = {}
|
||||
username = None
|
||||
api_key_name = getattr(user_api_key, "name", None) if user_api_key else None
|
||||
|
||||
if user_api_key is not None:
|
||||
try:
|
||||
user = getattr(user_api_key, "user", None)
|
||||
except Exception:
|
||||
user = None
|
||||
username = getattr(user, "username", None) if user is not None else None
|
||||
|
||||
# 只保存启用的能力(值为 True 的)
|
||||
active_capabilities = None
|
||||
if required_capabilities:
|
||||
active_capabilities = {k: v for k, v in required_capabilities.items() if v}
|
||||
if not active_capabilities:
|
||||
active_capabilities = None
|
||||
|
||||
def _retry_slots_for_candidate(candidate: ProviderCandidate) -> int:
|
||||
if not expand_retries:
|
||||
return 1
|
||||
return int(candidate.provider.max_retries or 2) if candidate.is_cached else 1
|
||||
|
||||
for candidate_index, candidate in enumerate(all_candidates):
|
||||
provider = candidate.provider
|
||||
endpoint = candidate.endpoint
|
||||
key = candidate.key
|
||||
pool_extra = (
|
||||
getattr(candidate, "_pool_extra_data", None)
|
||||
if isinstance(getattr(candidate, "_pool_extra_data", None), dict)
|
||||
else {}
|
||||
)
|
||||
base_extra = {
|
||||
"needs_conversion": candidate.needs_conversion,
|
||||
"provider_api_format": candidate.provider_api_format or None,
|
||||
"mapping_matched_model": candidate.mapping_matched_model or None,
|
||||
**pool_extra,
|
||||
}
|
||||
|
||||
if isinstance(candidate, PoolCandidate) and candidate.pool_keys:
|
||||
retry_slots = _retry_slots_for_candidate(candidate)
|
||||
for key_idx, pool_key in enumerate(candidate.pool_keys):
|
||||
key_id = str(pool_key.id)
|
||||
key_pool_extra = (
|
||||
getattr(pool_key, "_pool_extra_data", None)
|
||||
if isinstance(getattr(pool_key, "_pool_extra_data", None), dict)
|
||||
else {}
|
||||
)
|
||||
key_skipped = candidate.is_skipped or bool(
|
||||
getattr(pool_key, "_pool_skipped", False)
|
||||
)
|
||||
key_skip_reason_raw = (
|
||||
getattr(pool_key, "_pool_skip_reason", None) if key_skipped else None
|
||||
)
|
||||
key_skip_reason = (
|
||||
str(key_skip_reason_raw)
|
||||
if key_skip_reason_raw
|
||||
else (candidate.skip_reason if key_skipped else None)
|
||||
)
|
||||
mapping_model = getattr(pool_key, "_pool_mapping_matched_model", None)
|
||||
extra_data = {
|
||||
**base_extra,
|
||||
"mapping_matched_model": (
|
||||
mapping_model
|
||||
if mapping_model
|
||||
else base_extra.get("mapping_matched_model")
|
||||
),
|
||||
"pool_group_id": str(provider.id),
|
||||
"pool_key_index": key_idx,
|
||||
**key_pool_extra,
|
||||
}
|
||||
|
||||
for retry_in_key in range(retry_slots):
|
||||
retry_index = key_idx * retry_slots + retry_in_key
|
||||
status = "skipped" if key_skipped else "available"
|
||||
record_id = str(uuid.uuid4())
|
||||
candidate_records_to_insert.append(
|
||||
{
|
||||
"id": record_id,
|
||||
"request_id": request_id,
|
||||
"candidate_index": candidate_index,
|
||||
"retry_index": retry_index,
|
||||
"user_id": user_id,
|
||||
"api_key_id": user_api_key.id if user_api_key else None,
|
||||
"username": username,
|
||||
"api_key_name": api_key_name,
|
||||
"provider_id": provider.id,
|
||||
"endpoint_id": endpoint.id,
|
||||
"key_id": key_id,
|
||||
"status": status,
|
||||
"skip_reason": key_skip_reason if key_skipped else None,
|
||||
"is_cached": candidate.is_cached,
|
||||
"extra_data": extra_data,
|
||||
"required_capabilities": active_capabilities,
|
||||
"created_at": datetime.now(timezone.utc),
|
||||
}
|
||||
)
|
||||
candidate_record_map[(candidate_index, retry_index)] = record_id
|
||||
continue
|
||||
|
||||
if candidate.is_skipped:
|
||||
record_id = str(uuid.uuid4())
|
||||
candidate_records_to_insert.append(
|
||||
{
|
||||
"id": record_id,
|
||||
"request_id": request_id,
|
||||
"candidate_index": candidate_index,
|
||||
"retry_index": 0,
|
||||
"user_id": user_id,
|
||||
"api_key_id": user_api_key.id if user_api_key else None,
|
||||
"username": username,
|
||||
"api_key_name": api_key_name,
|
||||
"provider_id": provider.id,
|
||||
"endpoint_id": endpoint.id,
|
||||
"key_id": key.id,
|
||||
"status": "skipped",
|
||||
"skip_reason": candidate.skip_reason,
|
||||
"is_cached": candidate.is_cached,
|
||||
"extra_data": base_extra,
|
||||
"required_capabilities": active_capabilities,
|
||||
"created_at": datetime.now(timezone.utc),
|
||||
}
|
||||
)
|
||||
candidate_record_map[(candidate_index, 0)] = record_id
|
||||
else:
|
||||
max_retries_for_candidate = _retry_slots_for_candidate(candidate)
|
||||
|
||||
for retry_index in range(max_retries_for_candidate):
|
||||
record_id = str(uuid.uuid4())
|
||||
candidate_records_to_insert.append(
|
||||
{
|
||||
"id": record_id,
|
||||
"request_id": request_id,
|
||||
"candidate_index": candidate_index,
|
||||
"retry_index": retry_index,
|
||||
"user_id": user_id,
|
||||
"api_key_id": user_api_key.id if user_api_key else None,
|
||||
"username": username,
|
||||
"api_key_name": api_key_name,
|
||||
"provider_id": provider.id,
|
||||
"endpoint_id": endpoint.id,
|
||||
"key_id": key.id,
|
||||
"status": "available",
|
||||
"is_cached": candidate.is_cached,
|
||||
"extra_data": base_extra,
|
||||
"required_capabilities": active_capabilities,
|
||||
"created_at": datetime.now(timezone.utc),
|
||||
}
|
||||
)
|
||||
candidate_record_map[(candidate_index, retry_index)] = record_id
|
||||
|
||||
if candidate_records_to_insert:
|
||||
self.db.bulk_insert_mappings(
|
||||
RequestCandidate, candidate_records_to_insert # type: ignore
|
||||
)
|
||||
self.db.flush()
|
||||
|
||||
logger.debug(
|
||||
f" [{request_id}] 批量插入完成: {len(candidate_records_to_insert)} 条记录"
|
||||
)
|
||||
|
||||
return candidate_record_map
|
||||
|
||||
async def create_candidate_records_async(
|
||||
self,
|
||||
all_candidates: list[ProviderCandidate],
|
||||
request_id: str | None,
|
||||
user_id: str | None,
|
||||
user_api_key: ApiKey | None,
|
||||
required_capabilities: dict[str, bool] | None = None,
|
||||
*,
|
||||
expand_retries: bool = True,
|
||||
) -> dict[tuple[int, int], str]:
|
||||
"""异步版本的 create_candidate_records,将同步 DB 操作放到线程池执行,
|
||||
避免阻塞 asyncio 事件循环。"""
|
||||
return await asyncio.to_thread(
|
||||
self.create_candidate_records,
|
||||
all_candidates,
|
||||
request_id,
|
||||
user_id,
|
||||
user_api_key,
|
||||
required_capabilities,
|
||||
expand_retries=expand_retries,
|
||||
)
|
||||
|
||||
def get_active_candidates(
|
||||
self,
|
||||
all_candidates: list[ProviderCandidate],
|
||||
) -> list[tuple[int, ProviderCandidate]]:
|
||||
"""
|
||||
获取所有非跳过的候选(带索引)
|
||||
|
||||
Args:
|
||||
all_candidates: 所有候选组合
|
||||
|
||||
Returns:
|
||||
List of (index, candidate) for non-skipped candidates
|
||||
"""
|
||||
return [(i, c) for i, c in enumerate(all_candidates) if not c.is_skipped]
|
||||
|
||||
def count_total_attempts(
|
||||
self,
|
||||
all_candidates: list[ProviderCandidate],
|
||||
) -> int:
|
||||
"""
|
||||
计算总尝试次数
|
||||
|
||||
Args:
|
||||
all_candidates: 所有候选组合
|
||||
|
||||
Returns:
|
||||
总尝试次数
|
||||
"""
|
||||
total = 0
|
||||
for candidate in all_candidates:
|
||||
if not candidate.is_skipped:
|
||||
retries_per_slot = (
|
||||
int(candidate.provider.max_retries or 2) if candidate.is_cached else 1
|
||||
)
|
||||
if isinstance(candidate, PoolCandidate):
|
||||
schedulable_keys = [
|
||||
k
|
||||
for k in (candidate.pool_keys or [])
|
||||
if not bool(getattr(k, "_pool_skipped", False))
|
||||
]
|
||||
if schedulable_keys:
|
||||
total += len(schedulable_keys) * retries_per_slot
|
||||
elif candidate.pool_keys:
|
||||
# 兜底:尚未附加 _pool_skipped 标记时,按 key 数估算。
|
||||
total += len(candidate.pool_keys) * retries_per_slot
|
||||
else:
|
||||
total += retries_per_slot
|
||||
else:
|
||||
total += retries_per_slot
|
||||
return total
|
||||
627
_deprecated_py_src/services/orchestration/error_classifier.py
Normal file
627
_deprecated_py_src/services/orchestration/error_classifier.py
Normal file
@@ -0,0 +1,627 @@
|
||||
"""
|
||||
错误分类器
|
||||
|
||||
负责错误分类和处理策略决定
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from enum import Enum
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from src.core.exceptions import (
|
||||
ConcurrencyLimitError,
|
||||
ProviderAuthException,
|
||||
ProviderCompatibilityException,
|
||||
ProviderException,
|
||||
ProviderNotAvailableException,
|
||||
ProviderRateLimitException,
|
||||
ThinkingSignatureException,
|
||||
UpstreamClientException,
|
||||
)
|
||||
from src.core.logger import logger
|
||||
from src.models.database import Provider, ProviderAPIKey, ProviderEndpoint
|
||||
from src.services.orchestration.error_handler import ErrorHandlerService
|
||||
from src.services.rate_limit.adaptive_rpm import get_adaptive_rpm_manager
|
||||
from src.services.scheduling.aware_scheduler import CacheAwareScheduler
|
||||
|
||||
|
||||
class ErrorAction(Enum):
|
||||
"""错误处理动作"""
|
||||
|
||||
CONTINUE = "continue" # 继续重试当前候选
|
||||
BREAK = "break" # 跳到下一个候选
|
||||
RAISE = "raise" # 直接抛出异常
|
||||
|
||||
|
||||
class ErrorClassifier:
|
||||
"""
|
||||
错误分类器 - 负责错误分类和处理策略
|
||||
|
||||
职责:
|
||||
1. 将错误分类为可重试/不可重试
|
||||
2. 决定错误后的处理动作(重试/切换/放弃)
|
||||
3. 处理特定类型的错误(如 429 限流)
|
||||
4. 更新健康状态和缓存亲和性
|
||||
"""
|
||||
|
||||
# 需要触发故障转移的错误类型
|
||||
RETRIABLE_ERRORS: tuple[type, ...] = (
|
||||
ProviderException, # 包含所有 Provider 异常子类
|
||||
ConnectionError, # Python 标准连接错误
|
||||
TimeoutError, # Python 标准超时错误
|
||||
httpx.TransportError, # HTTPX 传输错误
|
||||
)
|
||||
|
||||
# 不可重试的错误类型(直接抛出)
|
||||
NON_RETRIABLE_ERRORS: tuple[type, ...] = (
|
||||
ValueError, # 参数错误
|
||||
TypeError, # 类型错误
|
||||
KeyError, # 键错误
|
||||
UpstreamClientException, # 上游客户端错误
|
||||
)
|
||||
|
||||
# 表示客户端请求错误的关键词(不区分大小写)
|
||||
# 这些错误是由用户请求本身导致的,换 Provider 也无济于事
|
||||
# 注意:标准 API 返回的 error.type 已在 CLIENT_ERROR_TYPES 中处理
|
||||
# 这里主要用于匹配非标准格式或第三方代理的错误消息
|
||||
#
|
||||
# 重要:不要在此列表中包含 Provider Key 配置问题(如 invalid_api_key)
|
||||
# 这类错误应该触发故障转移,而不是直接返回给用户
|
||||
CLIENT_ERROR_PATTERNS: tuple[str, ...] = (
|
||||
"could not process image", # 图片处理失败
|
||||
"image too large", # 图片过大
|
||||
"invalid image", # 无效图片
|
||||
"unsupported image", # 不支持的图片格式
|
||||
"content_policy_violation", # 内容违规
|
||||
"context_length_exceeded", # 上下文长度超限
|
||||
"content_length_limit", # 请求内容长度超限 (Claude API)
|
||||
"content_length_exceeds", # 内容长度超限变体 (AWS CodeWhisperer)
|
||||
# 注意:移除了 "max_tokens",因为 max_tokens 相关错误可能是 Provider 兼容性问题
|
||||
# 如 "Unsupported parameter: 'max_tokens' is not supported with this model"
|
||||
# 这类错误应由 COMPATIBILITY_ERROR_PATTERNS 处理
|
||||
"invalid_prompt", # 无效的提示词
|
||||
"content too long", # 内容过长
|
||||
"input is too long", # 输入过长 (AWS)
|
||||
"message is too long", # 消息过长
|
||||
"prompt is too long", # Prompt 超长(第三方代理常见格式)
|
||||
"image exceeds", # 图片超出限制
|
||||
"pdf too large", # PDF 过大
|
||||
"file too large", # 文件过大
|
||||
"tool_use_id", # tool_result 引用了不存在的 tool_use(兼容非标准代理)
|
||||
"validationexception", # AWS 验证异常
|
||||
)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
db: Session,
|
||||
adaptive_manager: Any | None = None,
|
||||
cache_scheduler: CacheAwareScheduler | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
初始化错误分类器
|
||||
|
||||
Args:
|
||||
db: 数据库会话
|
||||
adaptive_manager: 自适应并发管理器
|
||||
cache_scheduler: 缓存调度器(可选)
|
||||
"""
|
||||
self.db = db
|
||||
self.adaptive_manager = adaptive_manager or get_adaptive_rpm_manager()
|
||||
self.cache_scheduler = cache_scheduler
|
||||
self._error_handler = ErrorHandlerService(
|
||||
db=db,
|
||||
adaptive_manager=self.adaptive_manager,
|
||||
cache_scheduler=cache_scheduler,
|
||||
)
|
||||
|
||||
# 表示客户端错误的 error type(不区分大小写)
|
||||
# 这些 type 表明是请求本身的问题,不应重试
|
||||
CLIENT_ERROR_TYPES: tuple[str, ...] = (
|
||||
# Claude/OpenAI 标准
|
||||
"invalid_request_error",
|
||||
# Gemini
|
||||
"invalid_argument",
|
||||
"failed_precondition",
|
||||
# AWS
|
||||
"validationexception",
|
||||
# 通用
|
||||
"validation_error",
|
||||
"bad_request",
|
||||
)
|
||||
|
||||
# 表示客户端错误的 reason/code 字段值
|
||||
CLIENT_ERROR_REASONS: tuple[str, ...] = (
|
||||
"CONTENT_LENGTH_EXCEEDS_THRESHOLD",
|
||||
"CONTEXT_LENGTH_EXCEEDED",
|
||||
"MAX_TOKENS_EXCEEDED",
|
||||
"INVALID_CONTENT",
|
||||
"CONTENT_POLICY_VIOLATION",
|
||||
)
|
||||
|
||||
# Provider 兼容性错误模式 - 这类错误应该触发故障转移
|
||||
# 因为换一个 Provider 可能就能成功
|
||||
COMPATIBILITY_ERROR_PATTERNS: tuple[str, ...] = (
|
||||
"unsupported parameter", # 不支持的参数
|
||||
"unsupported model", # 不支持的模型
|
||||
"unsupported feature", # 不支持的功能
|
||||
"not supported with this model", # 此模型不支持
|
||||
"model does not support", # 模型不支持
|
||||
"parameter is not supported", # 参数不支持
|
||||
"feature is not supported", # 功能不支持
|
||||
"not available for this model", # 此模型不可用
|
||||
)
|
||||
|
||||
# Thinking 块相关错误模式 - 这类错误需要清洗 thinking 块或调整请求
|
||||
# 场景:多供应商环境下,Provider A 生成的 thinking 块被发送到 Provider B 时签名验证失败
|
||||
THINKING_ERROR_PATTERNS: tuple[str, ...] = (
|
||||
# 签名错误:跨 Provider 发送 thinking 块时,签名无法被目标 Provider 验证
|
||||
# 例: "invalid `signature` in `thinking` block: signature is for a different request"
|
||||
"invalid `signature` in `thinking` block",
|
||||
"invalid signature in thinking block",
|
||||
# 签名字段缺失或格式错误
|
||||
# 例: "messages.0.content.0.thinking.signature: field required"
|
||||
"thinking.signature: field required",
|
||||
"thinking.signature:", # 匹配路径模式 messages.X.content.X.thinking.signature: xxx
|
||||
"signature verification failed",
|
||||
# 结构错误:启用 thinking 时,有 tool_use 的 assistant 消息必须以 thinking 块开头
|
||||
# 例: "when `thinking` is enabled, the first content block ... must start with a `thinking` block"
|
||||
"must start with a thinking block",
|
||||
# 例: "expected thinking or redacted_thinking, found tool_use"
|
||||
"expected thinking or redacted_thinking",
|
||||
"expected `thinking`",
|
||||
"expected thinking, found", # 统一匹配 "found tool_use/text" 等变体
|
||||
"expected `thinking`, found", # 带反引号变体
|
||||
"expected redacted_thinking, found",
|
||||
"expected `redacted_thinking`, found",
|
||||
# Antigravity / Gemini-internal: thought signature validation
|
||||
"thoughtsignature",
|
||||
"thought_signature",
|
||||
)
|
||||
|
||||
def _parse_error_response(self, error_text: str | None) -> dict[str, Any]:
|
||||
"""
|
||||
解析错误响应为结构化数据
|
||||
|
||||
支持多种格式:
|
||||
- {"error": {"type": "...", "message": "..."}} (Claude/OpenAI)
|
||||
- {"error": {"message": "...", "__type": "..."}} (AWS)
|
||||
- {"errorMessage": "..."} (Lambda)
|
||||
- {"error": "..."}
|
||||
- {"message": "...", "reason": "..."}
|
||||
|
||||
Returns:
|
||||
结构化的错误信息: {
|
||||
"type": str, # 错误类型
|
||||
"message": str, # 错误消息
|
||||
"reason": str, # 错误原因/代码
|
||||
"raw": str, # 原始文本
|
||||
}
|
||||
"""
|
||||
result = {"type": "", "message": "", "reason": "", "raw": error_text or ""}
|
||||
|
||||
if not error_text:
|
||||
return result
|
||||
|
||||
try:
|
||||
data = json.loads(error_text)
|
||||
|
||||
# 格式 1: {"error": {"type": "...", "message": "..."}}
|
||||
if isinstance(data.get("error"), dict):
|
||||
error_obj = data["error"]
|
||||
result["type"] = str(error_obj.get("type", ""))
|
||||
result["message"] = str(error_obj.get("message", ""))
|
||||
|
||||
# AWS 格式: {"error": {"__type": "...", "message": "...", "reason": "..."}}
|
||||
# __type 直接在 error 对象中,而不是嵌套在 message 里
|
||||
if "__type" in error_obj:
|
||||
result["type"] = result["type"] or str(error_obj.get("__type", ""))
|
||||
if "reason" in error_obj:
|
||||
result["reason"] = str(error_obj.get("reason", ""))
|
||||
if "code" in error_obj:
|
||||
result["reason"] = result["reason"] or str(error_obj.get("code", ""))
|
||||
|
||||
# 嵌套 JSON 格式: message 字段本身是 JSON 字符串
|
||||
# 支持多种嵌套格式:
|
||||
# - AWS: {"__type": "...", "message": "...", "reason": "..."}
|
||||
# - 第三方代理: {"error": {"type": "...", "message": "..."}}
|
||||
if result["message"].startswith("{"):
|
||||
try:
|
||||
nested = json.loads(result["message"])
|
||||
if isinstance(nested, dict):
|
||||
# AWS 格式
|
||||
if "__type" in nested:
|
||||
result["type"] = result["type"] or str(nested.get("__type", ""))
|
||||
result["message"] = str(nested.get("message", result["message"]))
|
||||
result["reason"] = str(nested.get("reason", ""))
|
||||
# 第三方代理格式: {"error": {"message": "..."}}
|
||||
elif isinstance(nested.get("error"), dict):
|
||||
inner_error = nested["error"]
|
||||
inner_msg = str(inner_error.get("message", ""))
|
||||
if inner_msg:
|
||||
result["message"] = inner_msg
|
||||
# 简单格式: {"message": "..."}
|
||||
elif "message" in nested:
|
||||
result["message"] = str(nested["message"])
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
# 格式 2: {"error": "..."}
|
||||
elif isinstance(data.get("error"), str):
|
||||
result["message"] = str(data["error"])
|
||||
|
||||
# 格式 3: {"errorMessage": "..."} (Lambda)
|
||||
elif "errorMessage" in data:
|
||||
result["message"] = str(data["errorMessage"])
|
||||
|
||||
# 格式 4: {"message": "...", "reason": "..."}
|
||||
elif "message" in data:
|
||||
result["message"] = str(data["message"])
|
||||
result["reason"] = str(data.get("reason", ""))
|
||||
|
||||
# 提取顶层的 reason/code
|
||||
if not result["reason"]:
|
||||
result["reason"] = str(data.get("reason", data.get("code", "")))
|
||||
|
||||
except (json.JSONDecodeError, TypeError, KeyError):
|
||||
result["message"] = error_text
|
||||
|
||||
return result
|
||||
|
||||
def is_client_error(self, error_text: str | None) -> bool:
|
||||
"""
|
||||
检测错误响应是否为客户端错误(不应重试)
|
||||
|
||||
判断逻辑(按优先级):
|
||||
1. 检查 error.type 是否为已知的客户端错误类型
|
||||
2. 检查 reason/code 是否为已知的客户端错误原因
|
||||
3. 回退到关键词匹配
|
||||
|
||||
Args:
|
||||
error_text: 错误响应文本
|
||||
|
||||
Returns:
|
||||
是否为客户端错误
|
||||
"""
|
||||
if not error_text:
|
||||
return False
|
||||
|
||||
parsed = self._parse_error_response(error_text)
|
||||
|
||||
# 1. 检查 error type
|
||||
if parsed["type"]:
|
||||
error_type_lower = parsed["type"].lower()
|
||||
if any(t.lower() in error_type_lower for t in self.CLIENT_ERROR_TYPES):
|
||||
return True
|
||||
|
||||
# 2. 检查 reason/code
|
||||
if parsed["reason"]:
|
||||
reason_upper = parsed["reason"].upper()
|
||||
if any(r in reason_upper for r in self.CLIENT_ERROR_REASONS):
|
||||
return True
|
||||
|
||||
# 3. 回退到关键词匹配(合并 message 和 raw)
|
||||
search_text = f"{parsed['message']} {parsed['raw']}".lower()
|
||||
return any(pattern.lower() in search_text for pattern in self.CLIENT_ERROR_PATTERNS)
|
||||
|
||||
def _is_compatibility_error(self, error_text: str | None) -> bool:
|
||||
"""
|
||||
检测错误响应是否为 Provider 兼容性错误(应触发故障转移)
|
||||
|
||||
这类错误是因为 Provider 不支持某些参数或功能导致的,
|
||||
换一个 Provider 可能就能成功。
|
||||
|
||||
Args:
|
||||
error_text: 错误响应文本
|
||||
|
||||
Returns:
|
||||
是否为兼容性错误
|
||||
"""
|
||||
if not error_text:
|
||||
return False
|
||||
|
||||
search_text = error_text.lower()
|
||||
return any(pattern.lower() in search_text for pattern in self.COMPATIBILITY_ERROR_PATTERNS)
|
||||
|
||||
def _is_thinking_error(self, error_text: str | None) -> bool:
|
||||
"""
|
||||
检测错误响应是否为 Thinking 块相关错误(签名错误或结构错误)
|
||||
|
||||
这类错误通常发生在:
|
||||
1. 多供应商场景下,当一个供应商生成的 thinking 块被发送到另一个供应商时,签名验证会失败
|
||||
2. 请求体中有 tool_use 但没有以 thinking 块开头时,Claude 会报结构错误
|
||||
|
||||
Args:
|
||||
error_text: 错误响应文本
|
||||
|
||||
Returns:
|
||||
是否为 Thinking 相关错误
|
||||
"""
|
||||
if not error_text:
|
||||
return False
|
||||
search_text = error_text.lower()
|
||||
return any(p.lower() in search_text for p in self.THINKING_ERROR_PATTERNS)
|
||||
|
||||
def _extract_error_message(self, error_text: str | None) -> str | None:
|
||||
"""
|
||||
从错误响应中提取错误消息
|
||||
|
||||
Args:
|
||||
error_text: 错误响应文本
|
||||
|
||||
Returns:
|
||||
提取的错误消息
|
||||
"""
|
||||
if not error_text:
|
||||
return None
|
||||
|
||||
parsed = self._parse_error_response(error_text)
|
||||
|
||||
# 构建可读的错误消息
|
||||
parts = []
|
||||
if parsed["type"]:
|
||||
parts.append(parsed["type"])
|
||||
if parsed["reason"]:
|
||||
parts.append(f"[{parsed['reason']}]")
|
||||
if parsed["message"]:
|
||||
parts.append(parsed["message"])
|
||||
|
||||
if parts:
|
||||
return ": ".join(parts) if len(parts) > 1 else parts[0]
|
||||
|
||||
# 无法解析,返回原始文本
|
||||
return parsed["raw"]
|
||||
|
||||
def classify(
|
||||
self,
|
||||
error: Exception,
|
||||
has_retry_left: bool = False,
|
||||
) -> ErrorAction:
|
||||
"""
|
||||
分类错误,返回处理动作
|
||||
|
||||
默认全部转移策略: 不再返回 RAISE,所有错误都允许故障转移
|
||||
|
||||
Args:
|
||||
error: 异常对象
|
||||
has_retry_left: 当前候选是否还有重试次数
|
||||
|
||||
Returns:
|
||||
ErrorAction: 处理动作
|
||||
"""
|
||||
if isinstance(error, ConcurrencyLimitError):
|
||||
return ErrorAction.BREAK
|
||||
|
||||
if isinstance(error, httpx.HTTPStatusError):
|
||||
status_code = int(getattr(error.response, "status_code", 0) or 0)
|
||||
# 401/403 是认证/权限错误,在同一个 key 上重试无意义,直接跳到下一个候选
|
||||
if status_code in (401, 403):
|
||||
return ErrorAction.BREAK
|
||||
return ErrorAction.CONTINUE if has_retry_left else ErrorAction.BREAK
|
||||
|
||||
if isinstance(error, self.RETRIABLE_ERRORS):
|
||||
return ErrorAction.CONTINUE if has_retry_left else ErrorAction.BREAK
|
||||
|
||||
# 所有其他错误: 不再 RAISE,改为 BREAK(跳到下一个候选继续转移)
|
||||
return ErrorAction.BREAK
|
||||
|
||||
async def handle_rate_limit(
|
||||
self,
|
||||
key: ProviderAPIKey,
|
||||
provider_name: str,
|
||||
current_rpm: int | None,
|
||||
exception: ProviderRateLimitException,
|
||||
request_id: str | None = None,
|
||||
) -> str:
|
||||
"""委托给 ErrorHandlerService"""
|
||||
return await self._error_handler.handle_rate_limit(
|
||||
key=key,
|
||||
provider_name=provider_name,
|
||||
current_rpm=current_rpm,
|
||||
exception=exception,
|
||||
request_id=request_id,
|
||||
)
|
||||
|
||||
def convert_http_error(
|
||||
self,
|
||||
error: httpx.HTTPStatusError,
|
||||
provider_name: str,
|
||||
error_response_text: str | None = None,
|
||||
) -> ProviderException | UpstreamClientException:
|
||||
"""
|
||||
转换 HTTP 错误为 Provider 异常
|
||||
|
||||
Args:
|
||||
error: HTTP 状态错误
|
||||
provider_name: Provider 名称
|
||||
error_response_text: 错误响应文本(可选)
|
||||
|
||||
Returns:
|
||||
ProviderException 或 UpstreamClientException: 转换后的异常
|
||||
"""
|
||||
status = error.response.status_code if error.response else None
|
||||
|
||||
# 提取可读的错误消息
|
||||
extracted_message = self._extract_error_message(error_response_text)
|
||||
|
||||
# 构建详细错误信息(仅用于日志,不暴露给客户端)
|
||||
if extracted_message:
|
||||
detailed_message = f"上游服务返回错误 {status}: {extracted_message}"
|
||||
else:
|
||||
detailed_message = f"上游服务返回错误: {status}"
|
||||
|
||||
if status == 401:
|
||||
return ProviderAuthException(provider_name=provider_name)
|
||||
|
||||
# 403: 检查是否为 Google VALIDATION_REQUIRED(账号需要手动验证)
|
||||
# 这类错误是永久性的,重试同一个 key 无意义,应视为认证错误
|
||||
if status == 403 and ErrorHandlerService._is_account_validation_required(
|
||||
error_response_text
|
||||
):
|
||||
logger.warning("检测到 Google 账号验证要求 (VALIDATION_REQUIRED): {}", provider_name)
|
||||
return ProviderAuthException(provider_name=provider_name)
|
||||
|
||||
# 403: 检查是否为 AWS 账号被暂停(suspended)
|
||||
# 账号被封禁后所有请求都会返回 403,重试同一个 key 无意义
|
||||
if status == 403 and ErrorHandlerService._is_account_suspended(error_response_text):
|
||||
logger.warning("检测到 AWS 账号被暂停 (suspended): {}", provider_name)
|
||||
return ProviderAuthException(provider_name=provider_name)
|
||||
|
||||
if status == 429:
|
||||
return ProviderRateLimitException(
|
||||
message="请求过于频繁,请稍后重试",
|
||||
provider_name=provider_name,
|
||||
response_headers=dict(error.response.headers) if error.response else None,
|
||||
retry_after=(
|
||||
int(error.response.headers.get("retry-after", 0))
|
||||
if error.response and error.response.headers.get("retry-after")
|
||||
else None
|
||||
),
|
||||
)
|
||||
|
||||
# 400 错误:检查是否为 Thinking 块签名错误
|
||||
if status == 400 and self._is_thinking_error(error_response_text):
|
||||
logger.info(f"检测到 Thinking 块错误: {extracted_message}")
|
||||
return ThinkingSignatureException(
|
||||
message=extracted_message or "Thinking block signature validation failed",
|
||||
provider_name=provider_name,
|
||||
upstream_error=error_response_text,
|
||||
)
|
||||
|
||||
# 400 错误:先检查是否为 Provider 兼容性错误(应触发故障转移)
|
||||
if status == 400 and self._is_compatibility_error(error_response_text):
|
||||
logger.info(f"检测到 Provider 兼容性错误,将触发故障转移: {extracted_message}")
|
||||
return ProviderCompatibilityException(
|
||||
message=extracted_message or "Provider 不支持此请求",
|
||||
provider_name=provider_name,
|
||||
status_code=400,
|
||||
upstream_error=error_response_text,
|
||||
)
|
||||
|
||||
# 400 错误:检查是否为客户端请求错误(不应重试)
|
||||
if status == 400 and self.is_client_error(error_response_text):
|
||||
logger.info(f"检测到客户端请求错误,不进行重试: {extracted_message}")
|
||||
return UpstreamClientException(
|
||||
message=extracted_message or "请求无效",
|
||||
provider_name=provider_name,
|
||||
status_code=400,
|
||||
upstream_error=error_response_text,
|
||||
)
|
||||
|
||||
if status and status >= 500:
|
||||
return ProviderNotAvailableException(
|
||||
message=detailed_message,
|
||||
provider_name=provider_name,
|
||||
upstream_status=status,
|
||||
upstream_response=error_response_text,
|
||||
)
|
||||
|
||||
return ProviderNotAvailableException(
|
||||
message=detailed_message,
|
||||
provider_name=provider_name,
|
||||
upstream_status=status,
|
||||
upstream_response=error_response_text,
|
||||
)
|
||||
|
||||
async def handle_http_error(
|
||||
self,
|
||||
http_error: httpx.HTTPStatusError,
|
||||
*,
|
||||
provider: Provider,
|
||||
endpoint: ProviderEndpoint,
|
||||
key: ProviderAPIKey,
|
||||
affinity_key: str,
|
||||
api_format: str,
|
||||
global_model_id: str,
|
||||
request_id: str | None,
|
||||
captured_key_concurrent: int | None,
|
||||
elapsed_ms: int | None,
|
||||
attempt: int,
|
||||
max_attempts: int,
|
||||
) -> dict[str, Any]:
|
||||
"""处理 HTTP 错误,返回 extra_data(分类 + 委托副作用给 ErrorHandlerService)"""
|
||||
provider_name = str(provider.name)
|
||||
|
||||
# 尝试读取错误响应内容
|
||||
error_response_text = getattr(http_error, "upstream_response", None)
|
||||
if not error_response_text:
|
||||
try:
|
||||
if http_error.response and hasattr(http_error.response, "text"):
|
||||
error_response_text = http_error.response.text
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
logger.warning(
|
||||
f" [{request_id}] HTTP错误 (attempt={attempt}/{max_attempts}): "
|
||||
f"{http_error.response.status_code if http_error.response else 'unknown'}"
|
||||
)
|
||||
|
||||
# 分类(纯逻辑)
|
||||
converted_error = self.convert_http_error(http_error, provider_name, error_response_text)
|
||||
|
||||
extra_data: dict[str, Any] = {
|
||||
"converted_error": converted_error,
|
||||
}
|
||||
if error_response_text:
|
||||
extra_data["error_response"] = error_response_text
|
||||
|
||||
if isinstance(converted_error, UpstreamClientException):
|
||||
logger.warning(
|
||||
f" [{request_id}] 客户端请求错误,不进行重试: {converted_error.message}"
|
||||
)
|
||||
return extra_data
|
||||
|
||||
# 副作用(委托给 ErrorHandlerService)
|
||||
await self._error_handler.handle_http_error(
|
||||
http_error,
|
||||
converted_error,
|
||||
error_response_text,
|
||||
provider=provider,
|
||||
endpoint=endpoint,
|
||||
key=key,
|
||||
affinity_key=affinity_key,
|
||||
api_format=api_format,
|
||||
global_model_id=global_model_id,
|
||||
request_id=request_id,
|
||||
captured_key_concurrent=captured_key_concurrent,
|
||||
)
|
||||
|
||||
return extra_data
|
||||
|
||||
async def handle_retriable_error(
|
||||
self,
|
||||
error: Exception,
|
||||
*,
|
||||
provider: Provider,
|
||||
endpoint: ProviderEndpoint,
|
||||
key: ProviderAPIKey,
|
||||
affinity_key: str,
|
||||
api_format: str,
|
||||
global_model_id: str,
|
||||
captured_key_concurrent: int | None,
|
||||
elapsed_ms: int | None,
|
||||
request_id: str | None,
|
||||
attempt: int,
|
||||
max_attempts: int,
|
||||
) -> None:
|
||||
"""委托给 ErrorHandlerService"""
|
||||
logger.warning(
|
||||
f" [{request_id}] 请求失败 (attempt={attempt}/{max_attempts}): "
|
||||
f"{type(error).__name__}: {str(error)}"
|
||||
)
|
||||
|
||||
await self._error_handler.handle_retriable_error(
|
||||
error,
|
||||
provider=provider,
|
||||
endpoint=endpoint,
|
||||
key=key,
|
||||
affinity_key=affinity_key,
|
||||
api_format=api_format,
|
||||
global_model_id=global_model_id,
|
||||
captured_key_concurrent=captured_key_concurrent,
|
||||
request_id=request_id,
|
||||
)
|
||||
574
_deprecated_py_src/services/orchestration/error_handler.py
Normal file
574
_deprecated_py_src/services/orchestration/error_handler.py
Normal file
@@ -0,0 +1,574 @@
|
||||
"""
|
||||
错误处理服务
|
||||
|
||||
负责错误发生后的副作用操作(缓存失效、健康记录、RPM 调整、OAuth Key 标记等)。
|
||||
与 ErrorClassifier(纯分类,无副作用)分离,遵循单一职责原则。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from src.core.api_format.signature import make_signature_key
|
||||
from src.core.crypto import CryptoService
|
||||
from src.core.exceptions import (
|
||||
ProviderAuthException,
|
||||
ProviderRateLimitException,
|
||||
UpstreamClientException,
|
||||
)
|
||||
from src.core.logger import logger
|
||||
from src.models.database import Provider, ProviderAPIKey, ProviderEndpoint
|
||||
from src.services.health.monitor import get_health_monitor
|
||||
from src.services.provider.format import normalize_endpoint_signature
|
||||
from src.services.provider.oauth_token import verify_oauth_before_account_block
|
||||
from src.services.provider.pool.config import parse_pool_config
|
||||
from src.services.rate_limit.adaptive_rpm import get_adaptive_rpm_manager
|
||||
from src.services.rate_limit.detector import RateLimitType, detect_rate_limit_type
|
||||
from src.services.scheduling.aware_scheduler import CacheAwareScheduler
|
||||
|
||||
|
||||
class ErrorHandlerService:
|
||||
"""
|
||||
错误处理服务 - 负责错误发生后的副作用操作
|
||||
|
||||
职责:
|
||||
1. 缓存亲和性失效
|
||||
2. 健康监控记录
|
||||
3. 429 自适应 RPM 调整
|
||||
4. OAuth Key 状态标记
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
db: Session,
|
||||
adaptive_manager: Any | None = None,
|
||||
cache_scheduler: CacheAwareScheduler | None = None,
|
||||
) -> None:
|
||||
self.db = db
|
||||
self.adaptive_manager = adaptive_manager or get_adaptive_rpm_manager()
|
||||
self.cache_scheduler = cache_scheduler
|
||||
|
||||
async def handle_rate_limit(
|
||||
self,
|
||||
key: ProviderAPIKey,
|
||||
provider_name: str,
|
||||
current_rpm: int | None,
|
||||
exception: ProviderRateLimitException,
|
||||
request_id: str | None = None,
|
||||
) -> str:
|
||||
"""
|
||||
处理 429 速率限制错误的自适应调整
|
||||
|
||||
Returns:
|
||||
限制类型: "concurrent" 或 "rpm" 或 "unknown"
|
||||
"""
|
||||
try:
|
||||
response_headers = {}
|
||||
if hasattr(exception, "response_headers"):
|
||||
response_headers = exception.response_headers or {}
|
||||
|
||||
rate_limit_info = detect_rate_limit_type(
|
||||
headers=response_headers,
|
||||
provider_name=provider_name,
|
||||
current_usage=current_rpm,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
" [{}] 429错误分析: 类型={}, retry_after={}s, 当前RPM={}",
|
||||
request_id,
|
||||
rate_limit_info.limit_type,
|
||||
rate_limit_info.retry_after,
|
||||
current_rpm,
|
||||
)
|
||||
|
||||
new_limit = self.adaptive_manager.handle_429_error(
|
||||
db=self.db,
|
||||
key=key,
|
||||
rate_limit_info=rate_limit_info,
|
||||
current_rpm=current_rpm,
|
||||
)
|
||||
|
||||
if rate_limit_info.limit_type == RateLimitType.CONCURRENT:
|
||||
logger.warning(" [{}] 并发限制触发(不调整RPM)", request_id)
|
||||
return "concurrent"
|
||||
elif rate_limit_info.limit_type == RateLimitType.RPM:
|
||||
if new_limit is not None:
|
||||
logger.warning(
|
||||
" [{}] 自适应调整: Key {}... RPM限制 -> {}",
|
||||
request_id,
|
||||
str(key.id)[:8],
|
||||
new_limit,
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
" [{}] 学习中: Key {}... 观察已记录,暂不设限",
|
||||
request_id,
|
||||
str(key.id)[:8],
|
||||
)
|
||||
return "rpm"
|
||||
else:
|
||||
return "unknown"
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(" [{}] 处理429错误时异常: {}", request_id, e)
|
||||
return "unknown"
|
||||
|
||||
async def handle_http_error(
|
||||
self,
|
||||
http_error: httpx.HTTPStatusError,
|
||||
converted_error: Exception,
|
||||
error_response_text: str | None,
|
||||
*,
|
||||
provider: Provider,
|
||||
endpoint: ProviderEndpoint,
|
||||
key: ProviderAPIKey,
|
||||
affinity_key: str,
|
||||
api_format: str,
|
||||
global_model_id: str,
|
||||
request_id: str | None,
|
||||
captured_key_concurrent: int | None,
|
||||
) -> None:
|
||||
"""
|
||||
处理 HTTP 错误的副作用(缓存失效、健康记录、OAuth 标记)。
|
||||
|
||||
纯副作用方法:不返回分类结果,不做错误转换。
|
||||
"""
|
||||
client_format_str = normalize_endpoint_signature(api_format)
|
||||
fam = str(getattr(endpoint, "api_family", "")).strip().lower()
|
||||
kind = str(getattr(endpoint, "endpoint_kind", "")).strip().lower()
|
||||
provider_format_str = make_signature_key(fam, kind) if fam and kind else client_format_str
|
||||
|
||||
# 客户端请求错误:不失效缓存,不记录健康失败
|
||||
if isinstance(converted_error, UpstreamClientException):
|
||||
return
|
||||
|
||||
can_invalidate = bool(endpoint and key and self.cache_scheduler is not None)
|
||||
|
||||
# 认证错误
|
||||
if isinstance(converted_error, ProviderAuthException):
|
||||
if can_invalidate:
|
||||
await self._invalidate_cache(
|
||||
affinity_key, client_format_str, global_model_id, endpoint, key
|
||||
)
|
||||
if key:
|
||||
await asyncio.to_thread(
|
||||
get_health_monitor().record_failure,
|
||||
db=self.db,
|
||||
key_id=str(key.id),
|
||||
api_format=provider_format_str,
|
||||
error_type="ProviderAuthException",
|
||||
)
|
||||
# 403 VALIDATION_REQUIRED -> 标记 OAuth key 为账号级别封禁
|
||||
status_code = http_error.response.status_code if http_error.response else None
|
||||
if (
|
||||
status_code == 403
|
||||
and key
|
||||
and str(getattr(key, "auth_type", "") or "").lower() == "oauth"
|
||||
and self._is_account_validation_required(error_response_text)
|
||||
):
|
||||
should_mark = await self._verify_oauth_before_account_block(
|
||||
endpoint=endpoint,
|
||||
key=key,
|
||||
request_id=request_id,
|
||||
candidate_reason="Google 要求验证账号",
|
||||
)
|
||||
if should_mark:
|
||||
self._mark_oauth_key_blocked(key, request_id, provider=provider)
|
||||
# 403 suspended -> 标记 OAuth key 为账号被暂停
|
||||
elif (
|
||||
status_code == 403
|
||||
and key
|
||||
and str(getattr(key, "auth_type", "") or "").lower() == "oauth"
|
||||
and self._is_account_suspended(error_response_text)
|
||||
):
|
||||
should_mark = await self._verify_oauth_before_account_block(
|
||||
endpoint=endpoint,
|
||||
key=key,
|
||||
request_id=request_id,
|
||||
candidate_reason="AWS 账号被暂停",
|
||||
)
|
||||
if should_mark:
|
||||
self._mark_oauth_key_blocked(
|
||||
key,
|
||||
request_id,
|
||||
reason="AWS 账号被暂停",
|
||||
provider=provider,
|
||||
)
|
||||
# 401 account_deactivated -> 标记 OAuth key 为账号被永久停用
|
||||
elif (
|
||||
status_code == 401
|
||||
and key
|
||||
and str(getattr(key, "auth_type", "") or "").lower() == "oauth"
|
||||
and self._is_account_deactivated(error_response_text)
|
||||
):
|
||||
should_mark = await self._verify_oauth_before_account_block(
|
||||
endpoint=endpoint,
|
||||
key=key,
|
||||
request_id=request_id,
|
||||
candidate_reason="账号已被停用 (account_deactivated)",
|
||||
)
|
||||
if should_mark:
|
||||
self._mark_oauth_key_blocked(
|
||||
key,
|
||||
request_id,
|
||||
reason="账号已被停用 (account_deactivated)",
|
||||
provider=provider,
|
||||
)
|
||||
return
|
||||
|
||||
# 限流错误
|
||||
if isinstance(converted_error, ProviderRateLimitException) and key:
|
||||
await self.handle_rate_limit(
|
||||
key=key,
|
||||
provider_name=str(provider.name),
|
||||
current_rpm=captured_key_concurrent,
|
||||
exception=converted_error,
|
||||
request_id=request_id,
|
||||
)
|
||||
self._sync_gemini_cli_quota_state(
|
||||
key=key,
|
||||
provider=provider,
|
||||
model_name=global_model_id,
|
||||
error_text=error_response_text,
|
||||
request_id=request_id,
|
||||
)
|
||||
|
||||
# 所有非客户端错误均失效缓存
|
||||
if can_invalidate:
|
||||
await self._invalidate_cache(
|
||||
affinity_key, client_format_str, global_model_id, endpoint, key
|
||||
)
|
||||
|
||||
# 记录健康失败
|
||||
if key:
|
||||
await asyncio.to_thread(
|
||||
get_health_monitor().record_failure,
|
||||
db=self.db,
|
||||
key_id=str(key.id),
|
||||
api_format=provider_format_str,
|
||||
error_type=type(converted_error).__name__,
|
||||
)
|
||||
|
||||
async def handle_retriable_error(
|
||||
self,
|
||||
error: Exception,
|
||||
*,
|
||||
provider: Provider,
|
||||
endpoint: ProviderEndpoint,
|
||||
key: ProviderAPIKey,
|
||||
affinity_key: str,
|
||||
api_format: str,
|
||||
global_model_id: str,
|
||||
captured_key_concurrent: int | None,
|
||||
request_id: str | None,
|
||||
) -> None:
|
||||
"""处理可重试错误的副作用(缓存失效、健康记录)"""
|
||||
client_format_str = normalize_endpoint_signature(api_format)
|
||||
fam = str(getattr(endpoint, "api_family", "")).strip().lower()
|
||||
kind = str(getattr(endpoint, "endpoint_kind", "")).strip().lower()
|
||||
provider_format_str = make_signature_key(fam, kind) if fam and kind else client_format_str
|
||||
|
||||
# 限流错误
|
||||
if isinstance(error, ProviderRateLimitException) and key:
|
||||
await self.handle_rate_limit(
|
||||
key=key,
|
||||
provider_name=str(provider.name),
|
||||
current_rpm=captured_key_concurrent,
|
||||
exception=error,
|
||||
request_id=request_id,
|
||||
)
|
||||
|
||||
# 失效缓存
|
||||
if endpoint and key and self.cache_scheduler is not None:
|
||||
await self._invalidate_cache(
|
||||
affinity_key, client_format_str, global_model_id, endpoint, key
|
||||
)
|
||||
|
||||
# 记录健康失败
|
||||
if key:
|
||||
await asyncio.to_thread(
|
||||
get_health_monitor().record_failure,
|
||||
db=self.db,
|
||||
key_id=str(key.id),
|
||||
api_format=provider_format_str,
|
||||
error_type=type(error).__name__,
|
||||
)
|
||||
|
||||
async def _invalidate_cache(
|
||||
self,
|
||||
affinity_key: str,
|
||||
api_format: str,
|
||||
global_model_id: str,
|
||||
endpoint: ProviderEndpoint,
|
||||
key: ProviderAPIKey,
|
||||
) -> None:
|
||||
"""失效缓存亲和性(调用方需确保 cache_scheduler 可用)"""
|
||||
assert self.cache_scheduler is not None # noqa: S101
|
||||
await self.cache_scheduler.invalidate_cache(
|
||||
affinity_key=affinity_key,
|
||||
api_format=api_format,
|
||||
global_model_id=global_model_id,
|
||||
endpoint_id=str(endpoint.id),
|
||||
key_id=str(key.id),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _extract_oauth_email(key: ProviderAPIKey | None) -> str | None:
|
||||
"""从 OAuth Key 的加密 auth_config 中提取邮箱"""
|
||||
if not key or str(getattr(key, "auth_type", "") or "").lower() != "oauth":
|
||||
return None
|
||||
encrypted_auth_config = getattr(key, "auth_config", None)
|
||||
if not encrypted_auth_config:
|
||||
return None
|
||||
try:
|
||||
decrypted = CryptoService().decrypt(encrypted_auth_config, silent=True)
|
||||
auth_config = json.loads(decrypted) if decrypted else {}
|
||||
except Exception:
|
||||
return None
|
||||
email = auth_config.get("email")
|
||||
if isinstance(email, str):
|
||||
email = email.strip()
|
||||
if email:
|
||||
return email
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def _format_key_display(cls, key: ProviderAPIKey | None) -> str:
|
||||
"""格式化 Key 显示信息(用于日志)"""
|
||||
if not key:
|
||||
return "key=unknown"
|
||||
key_id = str(getattr(key, "id", "") or "")[:8] or "unknown"
|
||||
name = str(getattr(key, "name", "") or "").strip()
|
||||
email = cls._extract_oauth_email(key)
|
||||
parts = [f"key={key_id}"]
|
||||
if email:
|
||||
parts.append(f"email={email}")
|
||||
if name and name != email:
|
||||
parts.append(f"name={name}")
|
||||
return " ".join(parts)
|
||||
|
||||
@staticmethod
|
||||
def _is_account_validation_required(error_text: str | None) -> bool:
|
||||
"""
|
||||
检测 403 错误是否为 Google 账号验证要求 (VALIDATION_REQUIRED)
|
||||
|
||||
匹配条件(满足任一即可):
|
||||
- error.details 中包含 reason=VALIDATION_REQUIRED
|
||||
- error.status 为 PERMISSION_DENIED 且 message 包含 "verify your account"
|
||||
"""
|
||||
if not error_text:
|
||||
return False
|
||||
search_text = error_text.lower()
|
||||
if "validation_required" in search_text:
|
||||
return True
|
||||
if "verify your account" in search_text and "permission_denied" in search_text:
|
||||
return True
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _is_account_suspended(error_text: str | None) -> bool:
|
||||
"""
|
||||
检测 403 错误是否为 AWS 账号被暂停 (suspended)
|
||||
|
||||
匹配条件(满足任一即可):
|
||||
- 错误文本包含 "temporarily is suspended" 或 "temporarily suspended"
|
||||
- 错误文本包含 "AccountSuspendedException"
|
||||
- 错误文本匹配 User ID ... suspended 模式
|
||||
"""
|
||||
if not error_text:
|
||||
return False
|
||||
search_text = error_text.lower()
|
||||
if "temporarily is suspended" in search_text or "temporarily suspended" in search_text:
|
||||
return True
|
||||
if "accountsuspendedexception" in search_text:
|
||||
return True
|
||||
if re.search(r"user\s*id.*suspend", search_text):
|
||||
return True
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _is_account_deactivated(error_text: str | None) -> bool:
|
||||
"""
|
||||
检测 401 错误是否为账号被永久停用 (deactivated)
|
||||
|
||||
匹配条件:
|
||||
- 错误文本包含 "account_deactivated" (OpenAI error code)
|
||||
- 错误文本包含 "account has been deactivated"
|
||||
- 错误文本包含 "account deactivated"
|
||||
"""
|
||||
if not error_text:
|
||||
return False
|
||||
search_text = error_text.lower()
|
||||
if "account_deactivated" in search_text:
|
||||
return True
|
||||
if "account has been deactivated" in search_text:
|
||||
return True
|
||||
if "account deactivated" in search_text:
|
||||
return True
|
||||
return False
|
||||
|
||||
def _mark_oauth_key_blocked(
|
||||
self,
|
||||
key: ProviderAPIKey,
|
||||
request_id: str | None,
|
||||
reason: str = "Google 要求验证账号",
|
||||
*,
|
||||
provider: Provider,
|
||||
) -> None:
|
||||
"""标记 OAuth key 为账号级别封禁"""
|
||||
try:
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from src.services.provider.oauth_token import OAUTH_ACCOUNT_BLOCK_PREFIX
|
||||
from src.services.provider.pool.account_state import (
|
||||
resolve_pool_account_state,
|
||||
should_auto_remove_account_state,
|
||||
)
|
||||
|
||||
key.oauth_invalid_at = datetime.now(timezone.utc)
|
||||
key.oauth_invalid_reason = f"{OAUTH_ACCOUNT_BLOCK_PREFIX}{reason}"
|
||||
# 不设 is_active=False:oauth_invalid 标记已足够阻止调度,
|
||||
# 保持 is_active=True 使配额刷新仍能覆盖该 key,账号恢复后可自动解除。
|
||||
|
||||
pool_cfg = parse_pool_config(getattr(provider, "config", None))
|
||||
auto_remove_enabled = bool(pool_cfg and pool_cfg.auto_remove_banned_keys)
|
||||
account_state = resolve_pool_account_state(
|
||||
provider_type=str(getattr(provider, "provider_type", "") or ""),
|
||||
upstream_metadata=getattr(key, "upstream_metadata", None),
|
||||
oauth_invalid_reason=getattr(key, "oauth_invalid_reason", None),
|
||||
)
|
||||
|
||||
if auto_remove_enabled and should_auto_remove_account_state(account_state):
|
||||
key_id = str(getattr(key, "id", "") or "")
|
||||
provider_id = str(getattr(key, "provider_id", "") or "")
|
||||
display = self._format_key_display(key)
|
||||
|
||||
self.db.delete(key)
|
||||
self.db.commit()
|
||||
self._schedule_auto_cleanup_after_delete(provider_id=provider_id, key_id=key_id)
|
||||
logger.warning(
|
||||
" [{}] {} 因 {} 已标记为账号异常并自动清除",
|
||||
request_id,
|
||||
display,
|
||||
reason,
|
||||
)
|
||||
return
|
||||
|
||||
self.db.commit()
|
||||
logger.warning(
|
||||
" [{}] {} 因 {} 已标记为账号异常并阻止调度",
|
||||
request_id,
|
||||
self._format_key_display(key),
|
||||
reason,
|
||||
)
|
||||
except Exception as mark_exc:
|
||||
logger.debug(" [{}] 标记 oauth_invalid 失败: {}", request_id, mark_exc)
|
||||
|
||||
async def _verify_oauth_before_account_block(
|
||||
self,
|
||||
*,
|
||||
endpoint: ProviderEndpoint,
|
||||
key: ProviderAPIKey,
|
||||
request_id: str | None,
|
||||
candidate_reason: str,
|
||||
) -> bool:
|
||||
"""Before applying an account-level block, distinguish it from OAuth expiry."""
|
||||
return await verify_oauth_before_account_block(
|
||||
endpoint=endpoint,
|
||||
key=key,
|
||||
candidate_reason=candidate_reason,
|
||||
request_id=request_id,
|
||||
key_display=self._format_key_display(key),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _schedule_auto_cleanup_after_delete(*, provider_id: str, key_id: str) -> None:
|
||||
if not provider_id or not key_id:
|
||||
return
|
||||
|
||||
async def _cleanup() -> None:
|
||||
from src.services.cache.model_list_cache import invalidate_models_list_cache
|
||||
from src.services.cache.provider_cache import ProviderCacheService
|
||||
from src.services.provider.pool import redis_ops as pool_redis
|
||||
|
||||
await ProviderCacheService.invalidate_provider_api_key_cache(key_id)
|
||||
await invalidate_models_list_cache()
|
||||
await asyncio.gather(
|
||||
pool_redis.clear_cooldown(provider_id, key_id),
|
||||
pool_redis.clear_cost(provider_id, key_id),
|
||||
return_exceptions=True,
|
||||
)
|
||||
|
||||
task = asyncio.get_running_loop().create_task(_cleanup())
|
||||
|
||||
def _log_async_error(done_task: asyncio.Task[Any]) -> None:
|
||||
try:
|
||||
done_task.result()
|
||||
except Exception as exc:
|
||||
logger.debug("auto cleanup side effect failed for key {}: {}", key_id[:8], exc)
|
||||
|
||||
task.add_done_callback(_log_async_error)
|
||||
|
||||
def _sync_gemini_cli_quota_state(
|
||||
self,
|
||||
*,
|
||||
key: ProviderAPIKey | None,
|
||||
provider: Provider | None,
|
||||
model_name: str | None,
|
||||
error_text: str | None,
|
||||
request_id: str | None,
|
||||
) -> None:
|
||||
if key is None or provider is None:
|
||||
return
|
||||
from src.core.provider_types import ProviderType, normalize_provider_type
|
||||
|
||||
provider_type = normalize_provider_type(getattr(provider, "provider_type", None))
|
||||
if provider_type != ProviderType.GEMINI_CLI:
|
||||
return
|
||||
|
||||
normalized_model = str(model_name or "").strip()
|
||||
if not normalized_model:
|
||||
return
|
||||
|
||||
try:
|
||||
from src.services.model.upstream_fetcher import merge_upstream_metadata
|
||||
from src.services.provider.adapters.gemini_cli.quota import (
|
||||
build_quota_exhausted_metadata,
|
||||
extract_error_model_name,
|
||||
)
|
||||
|
||||
resolved_model = extract_error_model_name(error_text, fallback=normalized_model)
|
||||
if not resolved_model:
|
||||
return
|
||||
|
||||
current_metadata = (
|
||||
key.upstream_metadata if isinstance(key.upstream_metadata, dict) else {}
|
||||
)
|
||||
current_namespace = current_metadata.get("gemini_cli")
|
||||
namespace_dict = current_namespace if isinstance(current_namespace, dict) else None
|
||||
|
||||
updates = build_quota_exhausted_metadata(
|
||||
model_name=resolved_model,
|
||||
error_text=error_text,
|
||||
current_namespace=namespace_dict,
|
||||
)
|
||||
if not updates:
|
||||
return
|
||||
|
||||
key.upstream_metadata = merge_upstream_metadata(current_metadata, updates)
|
||||
self.db.add(key)
|
||||
self.db.commit()
|
||||
logger.info(
|
||||
" [{}] Gemini CLI key {} 记录模型冷却: {}",
|
||||
request_id,
|
||||
str(getattr(key, "id", "") or "")[:8],
|
||||
resolved_model,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.debug(" [{}] Gemini CLI 冷却元数据写入失败: {}", request_id, exc)
|
||||
170
_deprecated_py_src/services/orchestration/request_dispatcher.py
Normal file
170
_deprecated_py_src/services/orchestration/request_dispatcher.py
Normal file
@@ -0,0 +1,170 @@
|
||||
"""
|
||||
请求分发器
|
||||
|
||||
负责执行单个候选请求
|
||||
"""
|
||||
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from src.core.logger import logger
|
||||
from src.models.database import ApiKey
|
||||
from src.services.request.candidate import RequestCandidateService
|
||||
from src.services.request.executor import RequestExecutor
|
||||
from src.services.scheduling.aware_scheduler import CacheAwareScheduler, ProviderCandidate
|
||||
|
||||
|
||||
class RequestDispatcher:
|
||||
"""
|
||||
请求分发器 - 负责执行单个候选请求
|
||||
|
||||
职责:
|
||||
1. 执行请求并返回结果
|
||||
2. 更新候选状态(pending -> success/failed)
|
||||
3. 设置缓存亲和性(成功时)
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
db: Session,
|
||||
request_executor: RequestExecutor,
|
||||
cache_scheduler: CacheAwareScheduler | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
初始化请求分发器
|
||||
|
||||
Args:
|
||||
db: 数据库会话
|
||||
request_executor: 请求执行器
|
||||
cache_scheduler: 缓存调度器(可选)
|
||||
"""
|
||||
self.db = db
|
||||
self.request_executor = request_executor
|
||||
self.cache_scheduler = cache_scheduler
|
||||
|
||||
async def dispatch(
|
||||
self,
|
||||
candidate: ProviderCandidate,
|
||||
candidate_index: int,
|
||||
retry_index: int,
|
||||
candidate_record_id: str,
|
||||
user_api_key: ApiKey | None,
|
||||
user_id: str | None,
|
||||
request_func: Callable[..., Any],
|
||||
request_id: str | None,
|
||||
api_format: str,
|
||||
model_name: str,
|
||||
affinity_key: str,
|
||||
global_model_id: str,
|
||||
attempt_counter: int,
|
||||
max_attempts: int,
|
||||
is_stream: bool = False,
|
||||
) -> tuple[Any, str, str, str, str, str, int | None]:
|
||||
"""
|
||||
执行请求并返回结果
|
||||
|
||||
Args:
|
||||
candidate: 候选对象
|
||||
candidate_index: 候选索引
|
||||
retry_index: 重试索引
|
||||
candidate_record_id: 候选记录 ID
|
||||
user_api_key: 用户 API Key
|
||||
request_func: 请求函数
|
||||
request_id: 请求 ID
|
||||
api_format: API 格式
|
||||
model_name: 模型名称
|
||||
affinity_key: 亲和性标识符(通常为API Key ID)
|
||||
global_model_id: GlobalModel ID(规范化的模型标识,用于缓存亲和性)
|
||||
attempt_counter: 尝试计数
|
||||
max_attempts: 最大尝试次数
|
||||
is_stream: 是否为流式请求
|
||||
|
||||
Returns:
|
||||
(response, provider_name, candidate_record_id, provider_id, endpoint_id, key_id, ttfb_ms)
|
||||
|
||||
Raises:
|
||||
ExecutionError: 执行失败时
|
||||
"""
|
||||
provider = candidate.provider
|
||||
endpoint = candidate.endpoint
|
||||
key = candidate.key
|
||||
|
||||
# 显式转换为 str
|
||||
provider_id = str(provider.id)
|
||||
provider_name = str(provider.name)
|
||||
endpoint_id = str(endpoint.id)
|
||||
key_id = str(key.id)
|
||||
cache_ttl_minutes = int(key.cache_ttl_minutes or 0)
|
||||
provider_supports_caching = cache_ttl_minutes > 0
|
||||
provider_cache_ttl_seconds: int | None = (
|
||||
cache_ttl_minutes * 60 if cache_ttl_minutes > 0 else None
|
||||
)
|
||||
|
||||
# 更新状态为 pending
|
||||
RequestCandidateService.update_candidate_status(
|
||||
db=self.db, candidate_id=candidate_record_id, status="pending"
|
||||
)
|
||||
|
||||
# 执行请求
|
||||
execution_result = await self.request_executor.execute(
|
||||
candidate=candidate,
|
||||
candidate_id=candidate_record_id,
|
||||
candidate_index=candidate_index,
|
||||
user_api_key=user_api_key,
|
||||
user_id=user_id,
|
||||
request_func=request_func,
|
||||
request_id=request_id,
|
||||
api_format=api_format,
|
||||
model_name=model_name,
|
||||
is_stream=is_stream,
|
||||
)
|
||||
|
||||
context = execution_result.context
|
||||
elapsed_ms = context.elapsed_ms or 0
|
||||
|
||||
# 流式请求:标记为 streaming 状态(请求尚未完成)
|
||||
# 非流式请求:标记为 success 状态
|
||||
# 注意:executor.execute() 内部已经处理了状态标记,这里不再重复
|
||||
# 流式请求的 success 状态会在流完成后由 _record_stream_stats 方法标记
|
||||
|
||||
# 设置缓存亲和性
|
||||
if provider_supports_caching and self.cache_scheduler is not None:
|
||||
try:
|
||||
await self.cache_scheduler.set_cache_affinity(
|
||||
affinity_key=affinity_key,
|
||||
provider_id=provider_id,
|
||||
endpoint_id=endpoint_id,
|
||||
key_id=key_id,
|
||||
api_format=api_format,
|
||||
global_model_id=global_model_id,
|
||||
ttl=provider_cache_ttl_seconds,
|
||||
)
|
||||
except Exception as cache_exc:
|
||||
logger.warning(f" [{request_id}] 设置缓存亲和性失败: {cache_exc}")
|
||||
|
||||
logger.debug(f" [{request_id}] 请求成功: Provider={provider_name}, 耗时={elapsed_ms}ms")
|
||||
|
||||
# Non-stream requests don't have first-byte telemetry in this path.
|
||||
# Use elapsed latency as a conservative fallback for pool latency sampling.
|
||||
ttfb_ms: int | None = None
|
||||
if not is_stream:
|
||||
raw_ttfb = getattr(execution_result.response, "first_byte_time_ms", None)
|
||||
try:
|
||||
if raw_ttfb is not None:
|
||||
ttfb_ms = max(int(raw_ttfb), 0)
|
||||
except (TypeError, ValueError):
|
||||
ttfb_ms = None
|
||||
if ttfb_ms is None and elapsed_ms >= 0:
|
||||
ttfb_ms = int(elapsed_ms)
|
||||
|
||||
return (
|
||||
execution_result.response,
|
||||
provider_name,
|
||||
candidate_record_id,
|
||||
provider_id,
|
||||
endpoint_id,
|
||||
key_id,
|
||||
ttfb_ms,
|
||||
)
|
||||
Reference in New Issue
Block a user