refactor: 重构限流系统和健康监控,支持按 API 格式区分

- 将 adaptive_concurrency 重命名为 adaptive_rpm,从并发控制改为 RPM 控制
- 健康监控器支持按 API 格式独立管理健康度和熔断器状态
- 新增 model_permissions 模块,支持按格式配置允许的模型
- 重构前端提供商相关表单组件,新增 Collapsible UI 组件
- 新增数据库迁移脚本支持新的数据结构
This commit is contained in:
fawney19
2026-01-10 18:43:53 +08:00
parent dd2fbf4424
commit 09e0f594ff
97 changed files with 6642 additions and 4169 deletions

View File

@@ -36,6 +36,12 @@ class CacheService:
try:
return json.loads(value)
except (json.JSONDecodeError, TypeError):
# 非 JSON 值:统一返回字符串,避免上层出现 bytes/str 混用
if isinstance(value, (bytes, bytearray)):
try:
return value.decode("utf-8")
except Exception:
return value
return value
return None
@@ -98,6 +104,48 @@ class CacheService:
logger.warning(f"缓存删除失败: {key} - {e}")
return False
@staticmethod
async def delete_pattern(pattern: str, batch_size: int = 100) -> int:
"""
删除匹配模式的所有缓存
使用 SCAN 遍历并分批删除,避免阻塞 Redis
Args:
pattern: 缓存键模式(支持 * 通配符)
batch_size: 每批删除的最大键数量
Returns:
删除的键数量
"""
try:
redis = await get_redis_client(require_redis=False)
if not redis:
return 0
# 使用 SCAN 遍历匹配的键
deleted_count = 0
cursor = 0
while True:
cursor, keys = await redis.scan(cursor, match=pattern, count=batch_size)
if keys:
# 分批删除,避免单次删除过多键导致 Redis 阻塞
for i in range(0, len(keys), batch_size):
batch = keys[i : i + batch_size]
await redis.delete(*batch)
deleted_count += len(batch)
if cursor == 0:
break
if deleted_count > 0:
logger.debug(f"缓存模式删除成功: {pattern}, 删除 {deleted_count} 个键")
return deleted_count
except Exception as e:
logger.warning(f"缓存模式删除失败: {pattern} - {e}")
return 0
@staticmethod
async def exists(key: str) -> bool:
"""

View File

@@ -7,16 +7,19 @@ from typing import Optional
def extract_error_message(error: Exception, status_code: Optional[int] = None) -> str:
"""
从异常中提取错误消息,优先使用上游响应内容
从异常中提取错误消息,优先使用上游原始响应(用于链路追踪/调试)
此函数用于 RequestCandidate 表的 error_message 字段,
用于请求链路追踪中显示原始 Provider 响应。
Args:
error: 异常对象
status_code: 可选的 HTTP 状态码,用于构建更详细的错误消息
Returns:
错误消息字符串
错误消息字符串(原始 Provider 响应)
"""
# 优先使用 upstream_response 属性(包含上游 Provider 的原始错误)
# 优先使用 upstream_response 属性(包含上游 Provider 的原始错误,用于调试
upstream_response = getattr(error, "upstream_response", None)
if upstream_response and isinstance(upstream_response, str) and upstream_response.strip():
return str(upstream_response)
@@ -26,3 +29,25 @@ def extract_error_message(error: Exception, status_code: Optional[int] = None) -
if status_code is not None:
return f"HTTP {status_code}: {error_str}"
return error_str
def extract_client_error_message(error: Exception) -> str:
"""
从异常中提取客户端友好的错误消息(用于返回给客户端/Usage 记录)
此函数用于 Usage 表的 error_message 字段,
用于显示给最终用户的友好错误消息。
Args:
error: 异常对象
Returns:
友好的错误消息字符串
"""
# 优先使用 message 属性(已经是友好处理过的消息)
message = getattr(error, "message", None)
if message and isinstance(message, str) and message.strip():
return message
# 回退到异常的字符串表示
return str(error) or repr(error)

View File

@@ -205,7 +205,7 @@ class ProviderTimeoutException(ProviderException):
def __init__(self, provider_name: str, timeout: int, request_metadata: Optional[Any] = None):
super().__init__(
message=f"提供商 '{provider_name}' 请求超时({timeout}秒)",
message=f"请求超时({timeout}秒)",
provider_name=provider_name,
request_metadata=request_metadata,
timeout=timeout,
@@ -217,7 +217,7 @@ class ProviderAuthException(ProviderException):
def __init__(self, provider_name: str, request_metadata: Optional[Any] = None):
super().__init__(
message=f"提供商 '{provider_name}' 认证失败请检查API密钥",
message="上游服务认证失败",
provider_name=provider_name,
request_metadata=request_metadata,
)
@@ -292,9 +292,8 @@ class ModelNotSupportedException(ProxyException):
"""模型不支持"""
def __init__(self, model: str, provider_name: Optional[str] = None):
# 客户端消息不暴露提供商信息
message = f"模型 '{model}' 不受支持"
if provider_name:
message = f"提供商 '{provider_name}' 不支持模型 '{model}'"
super().__init__(
status_code=status.HTTP_400_BAD_REQUEST,
error_type="model_not_supported",
@@ -307,10 +306,8 @@ class StreamingNotSupportedException(ProxyException):
"""流式请求不支持"""
def __init__(self, model: str, provider_name: Optional[str] = None):
if provider_name:
message = f"模型 '{model}' 在提供商 '{provider_name}'不支持流式请求"
else:
message = f"模型 '{model}' 不支持流式请求"
# 客户端消息不暴露提供商信息
message = f"模型 '{model}' 不支持流式请求"
super().__init__(
status_code=status.HTTP_400_BAD_REQUEST,
error_type="streaming_not_supported",
@@ -389,7 +386,7 @@ class JSONParseException(ProviderException):
details["response_content"] = response_content
super().__init__(
message=f"提供商 '{provider_name}' 返回了无效的JSON响应",
message="上游服务返回了无效的响应",
provider_name=provider_name,
request_metadata=request_metadata,
**details,
@@ -406,7 +403,7 @@ class EmptyStreamException(ProviderException):
request_metadata: Optional[Any] = None,
):
super().__init__(
message=f"提供商 '{provider_name}' 返回了空的流式响应status=200 但无数据)",
message="上游服务返回了空的流式响应",
provider_name=provider_name,
request_metadata=request_metadata,
chunk_count=chunk_count,
@@ -428,11 +425,10 @@ class EmbeddedErrorException(ProviderException):
error_status: Optional[str] = None,
request_metadata: Optional[Any] = None,
):
message = f"提供商 '{provider_name}' 返回了嵌套错误"
# 客户端消息不暴露提供商信息
message = "上游服务返回了错误"
if error_code:
message += f" (code={error_code})"
if error_message:
message += f": {error_message}"
super().__init__(
message=message,
@@ -549,12 +545,14 @@ class ErrorResponse:
if isinstance(e, ProxyException):
details = e.details.copy() if e.details else {}
status_code = e.status_code
message = e.message
# 如果是 ProviderNotAvailableException 且有上游错误,直接透传上游信息
if isinstance(e, ProviderNotAvailableException) and e.upstream_response:
message = e.message # 使用友好的错误消息
# 如果是 ProviderNotAvailableException 且有上游错误信息
if isinstance(e, ProviderNotAvailableException):
if e.upstream_status:
status_code = e.upstream_status
message = e.upstream_response
# upstream_response 存入 details 供请求链路追踪使用,不作为客户端消息
if e.upstream_response:
details["upstream_response"] = e.upstream_response
return ErrorResponse.create(
error_type=e.error_type,
message=message,

View File

@@ -0,0 +1,286 @@
"""
模型权限工具
支持两种 allowed_models 格式:
1. 简单模式(列表): ["claude-sonnet-4", "gpt-4o"]
2. 按格式模式(字典): {"OPENAI": ["gpt-4o"], "CLAUDE": ["claude-sonnet-4"]}
使用 None/null 表示不限制(允许所有模型)
"""
from typing import Any, Dict, List, Optional, Set, Union
# 类型别名
AllowedModels = Optional[Union[List[str], Dict[str, List[str]]]]
def normalize_allowed_models(
allowed_models: AllowedModels,
api_format: Optional[str] = None,
) -> Optional[Set[str]]:
"""
将 allowed_models 规范化为模型名称集合
Args:
allowed_models: 允许的模型配置(列表或字典)
api_format: 当前请求的 API 格式(用于字典模式)
Returns:
- None: 不限制(允许所有模型)
- Set[str]: 允许的模型名称集合(可能为空集,表示拒绝所有)
"""
if allowed_models is None:
return None
# 简单模式:直接是列表
if isinstance(allowed_models, list):
return set(allowed_models)
# 按格式模式:字典
if isinstance(allowed_models, dict):
if api_format is None:
# 没有指定格式,合并所有格式的模型
all_models: Set[str] = set()
for models in allowed_models.values():
if isinstance(models, list):
all_models.update(models)
return all_models if all_models else None
# 查找指定格式的模型列表
api_format_upper = api_format.upper()
models = allowed_models.get(api_format_upper)
if models is None:
# 该格式未配置,检查是否有通配符 "*"
models = allowed_models.get("*")
if models is None:
# 字典模式下未配置的格式 = 不限制该格式
return None
return set(models) if isinstance(models, list) else None
# 未知类型,视为不限制
return None
def check_model_allowed(
model_name: str,
allowed_models: AllowedModels,
api_format: Optional[str] = None,
resolved_model_name: Optional[str] = None,
) -> bool:
"""
检查模型是否被允许
Args:
model_name: 请求的模型名称
allowed_models: 允许的模型配置
api_format: 当前请求的 API 格式
resolved_model_name: 解析后的 GlobalModel.name可选
Returns:
True: 允许使用该模型
False: 不允许使用该模型
"""
allowed_set = normalize_allowed_models(allowed_models, api_format)
if allowed_set is None:
# 不限制
return True
if len(allowed_set) == 0:
# 空集合 = 拒绝所有
return False
# 检查请求的模型名或解析后的名称是否在白名单中
if model_name in allowed_set:
return True
if resolved_model_name and resolved_model_name in allowed_set:
return True
return False
def merge_allowed_models(
allowed_models_1: AllowedModels,
allowed_models_2: AllowedModels,
) -> AllowedModels:
"""
合并两个 allowed_models 配置,取交集
规则:
- 如果任一为 None返回另一个
- 如果都有值,取交集
- 如果都是列表,取列表交集
- 如果有字典,按 API 格式分别取交集(保持字典语义,不丢失格式区分信息)
Args:
allowed_models_1: 第一个配置
allowed_models_2: 第二个配置
Returns:
合并后的配置
"""
if allowed_models_1 is None:
return allowed_models_2
if allowed_models_2 is None:
return allowed_models_1
# 两个都是简单列表:直接取交集(返回确定性顺序)
if isinstance(allowed_models_1, list) and isinstance(allowed_models_2, list):
intersection = set(allowed_models_1) & set(allowed_models_2)
return sorted(intersection) if intersection else []
# 任一为字典模式:按 API 格式分别取交集,避免把 dict 合并成 list 导致权限过宽
from src.core.enums import APIFormat
def merge_sets(a: Optional[Set[str]], b: Optional[Set[str]]) -> Optional[Set[str]]:
# None 表示不限制:交集规则下等价于“只受另一方限制”
if a is None:
return b
if b is None:
return a
return a & b
known_formats = [fmt.value for fmt in APIFormat]
per_format: Dict[str, Optional[Set[str]]] = {}
for fmt in known_formats:
s1 = normalize_allowed_models(allowed_models_1, api_format=fmt)
s2 = normalize_allowed_models(allowed_models_2, api_format=fmt)
per_format[fmt] = merge_sets(s1, s2)
# 计算默认(未知格式)的交集,用 "*" 作为默认值以覆盖未枚举的格式
default_s1 = normalize_allowed_models(allowed_models_1, api_format="__DEFAULT__")
default_s2 = normalize_allowed_models(allowed_models_2, api_format="__DEFAULT__")
default_set = merge_sets(default_s1, default_s2)
# 如果 default_set 非 None 且不存在“某些格式不限制”的情况,可用 "*" 作为默认规则并按需覆盖
can_use_wildcard = default_set is not None and all(v is not None for v in per_format.values())
merged_dict: Dict[str, List[str]] = {}
if can_use_wildcard and default_set is not None:
merged_dict["*"] = sorted(default_set)
for fmt, s in per_format.items():
# can_use_wildcard 保证 s 非 None
if s is not None and s != default_set:
merged_dict[fmt] = sorted(s)
else:
for fmt, s in per_format.items():
if s is None:
continue
merged_dict[fmt] = sorted(s)
if not merged_dict:
# 全部不限制
return None
return merged_dict
def get_allowed_models_preview(
allowed_models: AllowedModels,
max_items: int = 3,
) -> str:
"""
获取 allowed_models 的预览字符串(用于日志和错误消息)
Args:
allowed_models: 允许的模型配置
max_items: 最多显示的模型数
Returns:
预览字符串,如 "gpt-4o, claude-sonnet-4, ..."
"""
if allowed_models is None:
return "(不限制)"
all_models: Set[str] = set()
if isinstance(allowed_models, list):
all_models = set(allowed_models)
elif isinstance(allowed_models, dict):
for models in allowed_models.values():
if isinstance(models, list):
all_models.update(models)
if not all_models:
return "(无)"
sorted_models = sorted(all_models)
preview = ", ".join(sorted_models[:max_items])
if len(sorted_models) > max_items:
preview += f", ...共{len(sorted_models)}"
return preview
def is_format_mode(allowed_models: AllowedModels) -> bool:
"""
判断 allowed_models 是否为按格式模式
Args:
allowed_models: 允许的模型配置
Returns:
True: 按格式模式(字典)
False: 简单模式(列表或 None
"""
return isinstance(allowed_models, dict)
def convert_to_format_mode(
allowed_models: AllowedModels,
api_formats: Optional[List[str]] = None,
) -> Dict[str, List[str]]:
"""
将 allowed_models 转换为按格式模式
Args:
allowed_models: 原始配置
api_formats: 要应用的 API 格式列表
Returns:
按格式模式的配置
"""
if allowed_models is None:
return {}
if isinstance(allowed_models, dict):
return allowed_models
# 简单列表模式 -> 按格式模式
if isinstance(allowed_models, list):
if not api_formats:
return {"*": allowed_models}
return {fmt.upper(): list(allowed_models) for fmt in api_formats}
return {}
def convert_to_simple_mode(allowed_models: AllowedModels) -> Optional[List[str]]:
"""
将 allowed_models 转换为简单列表模式
Args:
allowed_models: 原始配置
Returns:
简单列表或 None
"""
if allowed_models is None:
return None
if isinstance(allowed_models, list):
return allowed_models
if isinstance(allowed_models, dict):
all_models: Set[str] = set()
for models in allowed_models.values():
if isinstance(models, list):
all_models.update(models)
return sorted(all_models) if all_models else None
return None