mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 17:30:23 +08:00
refactor: 重构限流系统和健康监控,支持按 API 格式区分
- 将 adaptive_concurrency 重命名为 adaptive_rpm,从并发控制改为 RPM 控制 - 健康监控器支持按 API 格式独立管理健康度和熔断器状态 - 新增 model_permissions 模块,支持按格式配置允许的模型 - 重构前端提供商相关表单组件,新增 Collapsible UI 组件 - 新增数据库迁移脚本支持新的数据结构
This commit is contained in:
@@ -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:
|
||||
"""
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
286
src/core/model_permissions.py
Normal file
286
src/core/model_permissions.py
Normal 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
|
||||
Reference in New Issue
Block a user