mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 09:20:22 +08:00
feat: 实现 GlobalModel 别名匹配系统
主要更改: - GlobalModel 支持 model_aliases 配置,允许使用正则表达式定义别名规则 - Provider Key 的 allowed_models 现在可以通过别名规则匹配 GlobalModel - 新增 ModelAliasesTab 组件用于管理模型别名配置 - Provider 详情页新增别名映射预览功能,展示 Key 白名单与 GlobalModel 别名的匹配关系 - 路由预览 API 返回 Key 的 allowed_models 信息 安全特性: - 使用 regex 库的原生超时保护(100ms)防止 ReDoS 攻击 - 别名规则数量限制(50 条/模型)和长度限制(200 字符) - 别名映射预览 API 添加超时保护和结果截断 其他改进: - GlobalModel 更新/删除时使用行级锁防止并发竞态 - 缓存失效逻辑优化,支持异步清理和正则缓存清空 - 路由 Tab 布局重构,使用 flexbox 替代绝对定位
This commit is contained in:
@@ -6,9 +6,27 @@
|
||||
2. 按格式模式(字典): {"OPENAI": ["gpt-4o"], "CLAUDE": ["claude-sonnet-4"]}
|
||||
|
||||
使用 None/null 表示不限制(允许所有模型)
|
||||
|
||||
支持模型别名匹配:
|
||||
- GlobalModel.config.model_aliases 定义别名模式
|
||||
- 别名模式支持正则表达式语法
|
||||
- 例如:claude-haiku-.* 可匹配 claude-haiku-4.5, claude-haiku-last
|
||||
- 使用 regex 库的原生超时保护(100ms)防止 ReDoS
|
||||
"""
|
||||
|
||||
from typing import Any, Dict, List, Optional, Set, Union
|
||||
import re
|
||||
from functools import lru_cache
|
||||
from typing import Dict, List, Optional, Set, Tuple, Union
|
||||
|
||||
import regex
|
||||
|
||||
from src.core.logger import logger
|
||||
|
||||
# 别名规则限制
|
||||
MAX_ALIASES_PER_MODEL = 50
|
||||
MAX_ALIAS_LENGTH = 200
|
||||
MAX_MODEL_NAME_LENGTH = 200 # 与 MAX_ALIAS_LENGTH 保持一致
|
||||
REGEX_MATCH_TIMEOUT_MS = 100 # 正则匹配超时(毫秒)
|
||||
|
||||
# 类型别名
|
||||
AllowedModels = Optional[Union[List[str], Dict[str, List[str]]]]
|
||||
@@ -284,3 +302,288 @@ def convert_to_simple_mode(allowed_models: AllowedModels) -> Optional[List[str]]
|
||||
return sorted(all_models) if all_models else None
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def parse_allowed_models_to_list(allowed_models: AllowedModels) -> List[str]:
|
||||
"""
|
||||
解析 allowed_models(支持 list 和 dict 格式)为统一的列表
|
||||
|
||||
与 convert_to_simple_mode 的区别:
|
||||
- 本函数返回空列表而非 None(用于 UI 展示)
|
||||
- convert_to_simple_mode 返回 None 表示不限制
|
||||
|
||||
Args:
|
||||
allowed_models: 允许的模型配置(列表或字典)
|
||||
|
||||
Returns:
|
||||
模型名称列表(可能为空)
|
||||
"""
|
||||
if allowed_models is None:
|
||||
return []
|
||||
|
||||
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)
|
||||
|
||||
return []
|
||||
|
||||
|
||||
def validate_alias_pattern(pattern: str) -> Tuple[bool, Optional[str]]:
|
||||
"""
|
||||
验证别名模式是否安全
|
||||
|
||||
Args:
|
||||
pattern: 待验证的正则模式
|
||||
|
||||
Returns:
|
||||
(is_valid, error_message)
|
||||
"""
|
||||
if not pattern or not pattern.strip():
|
||||
return False, "别名规则不能为空"
|
||||
|
||||
if len(pattern) > MAX_ALIAS_LENGTH:
|
||||
return False, f"别名规则过长 (最大 {MAX_ALIAS_LENGTH} 字符)"
|
||||
|
||||
# 尝试编译验证语法
|
||||
try:
|
||||
re.compile(f"^{pattern}$", re.IGNORECASE)
|
||||
except re.error as e:
|
||||
return False, f"正则表达式语法错误: {e}"
|
||||
|
||||
return True, None
|
||||
|
||||
|
||||
def validate_model_aliases(aliases: Optional[List[str]]) -> Tuple[bool, Optional[str]]:
|
||||
"""
|
||||
验证别名列表是否合法
|
||||
|
||||
Args:
|
||||
aliases: 别名列表
|
||||
|
||||
Returns:
|
||||
(is_valid, error_message)
|
||||
"""
|
||||
if not aliases:
|
||||
return True, None
|
||||
|
||||
if len(aliases) > MAX_ALIASES_PER_MODEL:
|
||||
return False, f"别名规则数量超限 (最大 {MAX_ALIASES_PER_MODEL} 条)"
|
||||
|
||||
for i, alias in enumerate(aliases):
|
||||
is_valid, error = validate_alias_pattern(alias)
|
||||
if not is_valid:
|
||||
return False, f"第 {i + 1} 条规则无效: {error}"
|
||||
|
||||
return True, None
|
||||
|
||||
|
||||
def validate_and_extract_model_aliases(
|
||||
config: Optional[dict],
|
||||
) -> Tuple[bool, Optional[str], Optional[List[str]]]:
|
||||
"""
|
||||
从 config 中验证并提取 model_aliases
|
||||
|
||||
用于 GlobalModel 创建/更新时的统一验证
|
||||
|
||||
Args:
|
||||
config: GlobalModel 的 config 字典
|
||||
|
||||
Returns:
|
||||
(is_valid, error_message, aliases):
|
||||
- is_valid: 验证是否通过
|
||||
- error_message: 错误信息(验证失败时)
|
||||
- aliases: 提取的别名列表(验证成功时)
|
||||
"""
|
||||
if not config or "model_aliases" not in config:
|
||||
return True, None, None
|
||||
|
||||
aliases = config.get("model_aliases")
|
||||
|
||||
# 允许显式设置为 None(表示清除别名)
|
||||
if aliases is None:
|
||||
return True, None, None
|
||||
|
||||
# 类型验证:必须是列表
|
||||
if not isinstance(aliases, list):
|
||||
return False, "model_aliases 必须是数组类型", None
|
||||
|
||||
# 元素类型验证:必须是字符串
|
||||
if not all(isinstance(a, str) for a in aliases):
|
||||
return False, "model_aliases 数组元素必须是字符串", None
|
||||
|
||||
# 业务规则验证
|
||||
is_valid, error = validate_model_aliases(aliases)
|
||||
if not is_valid:
|
||||
return False, error, None
|
||||
|
||||
return True, None, aliases
|
||||
|
||||
|
||||
@lru_cache(maxsize=2000)
|
||||
def _compile_pattern_cached(pattern: str) -> Optional[regex.Pattern]:
|
||||
"""
|
||||
编译正则模式(带 LRU 缓存)
|
||||
|
||||
Args:
|
||||
pattern: 正则模式字符串
|
||||
|
||||
Returns:
|
||||
编译后的正则对象,如果无效则返回 None
|
||||
"""
|
||||
try:
|
||||
return regex.compile(f"^{pattern}$", regex.IGNORECASE)
|
||||
except regex.error as e:
|
||||
logger.debug(f"正则编译失败: pattern={pattern}, error={e}")
|
||||
return None
|
||||
|
||||
|
||||
def clear_regex_cache() -> None:
|
||||
"""
|
||||
清空正则缓存
|
||||
|
||||
在 GlobalModel 别名更新时调用此函数以确保缓存一致性
|
||||
"""
|
||||
_compile_pattern_cached.cache_clear()
|
||||
logger.debug("[RegexCache] 缓存已清空")
|
||||
|
||||
|
||||
def _match_with_timeout(
|
||||
compiled_regex: regex.Pattern, text: str, timeout_ms: int = REGEX_MATCH_TIMEOUT_MS
|
||||
) -> Optional[bool]:
|
||||
"""
|
||||
带超时的正则匹配(使用 regex 库的原生超时支持)
|
||||
|
||||
相比 ThreadPoolExecutor 方案的优势:
|
||||
- C 层面中断匹配,不会留下僵尸线程
|
||||
- 更低的性能开销
|
||||
- 更精确的超时控制
|
||||
|
||||
Args:
|
||||
compiled_regex: 编译后的 regex.Pattern 对象
|
||||
text: 待匹配的文本
|
||||
timeout_ms: 超时时间(毫秒)
|
||||
|
||||
Returns:
|
||||
True: 匹配成功
|
||||
False: 匹配失败
|
||||
None: 超时或异常
|
||||
"""
|
||||
try:
|
||||
# regex 库的 timeout 参数单位是秒
|
||||
result = compiled_regex.match(text, timeout=timeout_ms / 1000.0)
|
||||
return result is not None
|
||||
except TimeoutError:
|
||||
logger.warning(
|
||||
f"正则匹配超时 ({timeout_ms}ms): pattern={compiled_regex.pattern[:50]}..., text={text[:50]}..."
|
||||
)
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.warning(f"正则匹配异常: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def match_model_with_pattern(pattern: str, model_name: str) -> bool:
|
||||
"""
|
||||
检查模型名是否匹配别名模式(支持正则表达式)
|
||||
|
||||
安全特性:
|
||||
- 长度限制检查
|
||||
- 正则编译缓存
|
||||
- 正则匹配超时保护(100ms,使用 regex 库原生超时)
|
||||
|
||||
Args:
|
||||
pattern: 别名模式,支持正则表达式语法
|
||||
model_name: 被检查的模型名(来自 Key 的 allowed_models)
|
||||
|
||||
Returns:
|
||||
True 如果匹配
|
||||
|
||||
示例:
|
||||
match_model_with_pattern("claude-haiku-.*", "claude-haiku-4.5") -> True
|
||||
match_model_with_pattern("gpt-4o", "gpt-4o") -> True
|
||||
match_model_with_pattern("gpt-4o", "gpt-4") -> False
|
||||
"""
|
||||
# 快速路径:精确匹配
|
||||
if pattern.lower() == model_name.lower():
|
||||
return True
|
||||
|
||||
# 长度检查
|
||||
if len(pattern) > MAX_ALIAS_LENGTH or len(model_name) > MAX_MODEL_NAME_LENGTH:
|
||||
return False
|
||||
|
||||
# 使用缓存的编译结果
|
||||
compiled = _compile_pattern_cached(pattern)
|
||||
if compiled is None:
|
||||
return False
|
||||
|
||||
# 使用带超时的匹配(regex 库原生支持)
|
||||
result = _match_with_timeout(compiled, model_name)
|
||||
return result is True
|
||||
|
||||
|
||||
def check_model_allowed_with_aliases(
|
||||
model_name: str,
|
||||
allowed_models: AllowedModels,
|
||||
api_format: Optional[str] = None,
|
||||
resolved_model_name: Optional[str] = None,
|
||||
model_aliases: Optional[List[str]] = None,
|
||||
) -> tuple[bool, Optional[str]]:
|
||||
"""
|
||||
检查模型是否被允许(支持别名通配符匹配)
|
||||
|
||||
匹配优先级:
|
||||
1. 精确匹配 model_name(用户请求的模型名)
|
||||
2. 精确匹配 resolved_model_name(GlobalModel.name)
|
||||
3. 遍历 model_aliases,检查每个别名是否匹配 allowed_models 中的任一项
|
||||
|
||||
别名匹配顺序说明:
|
||||
- 按 allowed_models 集合的迭代顺序遍历(通常为字母顺序,因为内部使用 set)
|
||||
- 对于每个 allowed_model,按 model_aliases 数组顺序依次尝试匹配
|
||||
- 返回第一个成功匹配的 allowed_model
|
||||
- 如需确定性行为,请确保 model_aliases 中的规则从最具体到最通用排序
|
||||
|
||||
Args:
|
||||
model_name: 请求的模型名称
|
||||
allowed_models: 允许的模型配置(来自 Provider Key)
|
||||
api_format: 当前请求的 API 格式
|
||||
resolved_model_name: 解析后的 GlobalModel.name
|
||||
model_aliases: GlobalModel 的别名列表(来自 config.model_aliases)
|
||||
|
||||
Returns:
|
||||
(is_allowed, matched_model_name):
|
||||
- is_allowed: 是否允许使用该模型
|
||||
- matched_model_name: 通过别名匹配到的模型名(仅别名匹配时有值,精确匹配时为 None)
|
||||
"""
|
||||
# 先尝试精确匹配(使用原有逻辑)
|
||||
if check_model_allowed(model_name, allowed_models, api_format, resolved_model_name):
|
||||
return True, None
|
||||
|
||||
# 如果精确匹配失败且有别名配置,尝试别名匹配
|
||||
if not model_aliases:
|
||||
return False, None
|
||||
|
||||
# 获取 allowed_models 的集合
|
||||
allowed_set = normalize_allowed_models(allowed_models, api_format)
|
||||
if allowed_set is None:
|
||||
# 不限制,已在 check_model_allowed 中返回 True
|
||||
return True, None
|
||||
|
||||
if len(allowed_set) == 0:
|
||||
# 空集合 = 拒绝所有
|
||||
return False, None
|
||||
|
||||
# 遍历 allowed_models 中的每个模型名,检查是否有别名能匹配
|
||||
# 注意:返回第一个匹配的模型名,匹配顺序由 allowed_set 迭代顺序和 model_aliases 数组顺序决定
|
||||
for allowed_model in allowed_set:
|
||||
for alias_pattern in model_aliases:
|
||||
if match_model_with_pattern(alias_pattern, allowed_model):
|
||||
# 返回匹配到的模型名,用于实际请求
|
||||
return True, allowed_model
|
||||
|
||||
return False, None
|
||||
|
||||
Reference in New Issue
Block a user