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:
fawney19
2026-01-13 16:04:15 +08:00
parent 9fea71a70c
commit 85decd7487
21 changed files with 3845 additions and 2308 deletions

View File

@@ -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_nameGlobalModel.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