refactor: 移除 Python 后端源码,全面迁移至 Rust gateway 架构

- 删除全部 Python 源码 (src/) 及 Alembic 迁移脚本,归档至 _deprecated_py_src/
- 重构 Rust gateway ai_pipeline: 拆分 planner/finalize 模块,新增 contracts/adaptation 层
- 重组 handlers 模块为 admin/public/proxy/internal/shared 子模块结构
- 新增 executor 模块,引入 Rust 原生数据库迁移 (aether-data/migrations)
- 简化 CI/Docker 构建流程,移除 base image 二级构建,统一为单一 app image
- 移除 Python 相关基础设施文件 (entrypoint.sh, gunicorn_conf.py, Dockerfile.base)
This commit is contained in:
fawney19
2026-04-03 16:26:16 +08:00
parent 8f26e1a31f
commit 1d9c77522a
868 changed files with 1735 additions and 2433 deletions

View File

@@ -0,0 +1,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",
]

View 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

View 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,
)

View 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=Falseoauth_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)

View 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,
)