mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-01 17:00:21 +08:00
fix: 修复模型权限正则匹配逻辑及相关缓存清除
- 修复 model_mappings 正则匹配不再受 candidate_models 限制 - Provider 启用/禁用/删除时清除 GlobalModel 解析缓存 - 优先级管理对话框中启用的 Provider 排在禁用的前面 - 修复请求时间线 skipped 状态颜色适配主题
This commit is contained in:
@@ -567,10 +567,20 @@ const availableFormats = computed(() => {
|
|||||||
return Object.keys(keysByFormat.value).sort()
|
return Object.keys(keysByFormat.value).sort()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 排序 providers:启用的在前,停用的在后,各自按优先级排序
|
||||||
|
function sortProvidersByActiveAndPriority(providers: ProviderWithEndpointsSummary[]) {
|
||||||
|
return [...providers].sort((a, b) => {
|
||||||
|
if (a.is_active !== b.is_active) {
|
||||||
|
return a.is_active ? -1 : 1
|
||||||
|
}
|
||||||
|
return a.provider_priority - b.provider_priority
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// 监听 props.providers 变化
|
// 监听 props.providers 变化
|
||||||
watch(() => props.providers, (newProviders) => {
|
watch(() => props.providers, (newProviders) => {
|
||||||
if (newProviders) {
|
if (newProviders) {
|
||||||
sortedProviders.value = [...newProviders].sort((a, b) => a.provider_priority - b.provider_priority)
|
sortedProviders.value = sortProvidersByActiveAndPriority(newProviders)
|
||||||
}
|
}
|
||||||
}, { immediate: true })
|
}, { immediate: true })
|
||||||
|
|
||||||
@@ -691,8 +701,8 @@ function finishEditProviderPriority(provider: ProviderWithEndpointsSummary, even
|
|||||||
provider_priority: newPriority
|
provider_priority: newPriority
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 按 provider_priority 重新排序
|
// 重新排序
|
||||||
sortedProviders.value = [...sortedProviders.value].sort((a, b) => a.provider_priority - b.provider_priority)
|
sortedProviders.value = sortProvidersByActiveAndPriority(sortedProviders.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
editingProviderPriority.value = null
|
editingProviderPriority.value = null
|
||||||
@@ -777,7 +787,7 @@ function handleProviderDrop(dropIndex: number) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// 重新排序
|
// 重新排序
|
||||||
sortedProviders.value = [...items].sort((a, b) => a.provider_priority - b.provider_priority)
|
sortedProviders.value = sortProvidersByActiveAndPriority(items)
|
||||||
draggedProvider.value = null
|
draggedProvider.value = null
|
||||||
dragOverProvider.value = null
|
dragOverProvider.value = null
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -905,7 +905,7 @@ const getStatusColorClass = (status: string) => {
|
|||||||
.sub-dot.status-failed { background: #ef4444; color: #ef4444; }
|
.sub-dot.status-failed { background: #ef4444; color: #ef4444; }
|
||||||
.sub-dot.status-cancelled { background: #f59e0b; color: #f59e0b; }
|
.sub-dot.status-cancelled { background: #f59e0b; color: #f59e0b; }
|
||||||
.sub-dot.status-pending { background: #3b82f6; color: #3b82f6; }
|
.sub-dot.status-pending { background: #3b82f6; color: #3b82f6; }
|
||||||
.sub-dot.status-skipped { background: #1f2937; color: #1f2937; }
|
.sub-dot.status-skipped { background: hsl(var(--primary)); color: hsl(var(--primary)); }
|
||||||
.sub-dot.status-available { background: #d1d5db; color: #d1d5db; }
|
.sub-dot.status-available { background: #d1d5db; color: #d1d5db; }
|
||||||
|
|
||||||
/* 选中状态:呼吸动画 + 涟漪效果 */
|
/* 选中状态:呼吸动画 + 涟漪效果 */
|
||||||
@@ -968,7 +968,7 @@ const getStatusColorClass = (status: string) => {
|
|||||||
.node-dot.status-failed { color: #ef4444; }
|
.node-dot.status-failed { color: #ef4444; }
|
||||||
.node-dot.status-cancelled { color: #f59e0b; }
|
.node-dot.status-cancelled { color: #f59e0b; }
|
||||||
.node-dot.status-pending { color: #3b82f6; }
|
.node-dot.status-pending { color: #3b82f6; }
|
||||||
.node-dot.status-skipped { color: #1f2937; }
|
.node-dot.status-skipped { color: hsl(var(--primary)); }
|
||||||
.node-dot.status-available { color: #d1d5db; }
|
.node-dot.status-available { color: #d1d5db; }
|
||||||
|
|
||||||
.node-line {
|
.node-line {
|
||||||
@@ -1016,7 +1016,7 @@ const getStatusColorClass = (status: string) => {
|
|||||||
.title-dot.status-failed { background: #ef4444; }
|
.title-dot.status-failed { background: #ef4444; }
|
||||||
.title-dot.status-cancelled { background: #f59e0b; }
|
.title-dot.status-cancelled { background: #f59e0b; }
|
||||||
.title-dot.status-pending { background: #3b82f6; }
|
.title-dot.status-pending { background: #3b82f6; }
|
||||||
.title-dot.status-skipped { background: #1f2937; }
|
.title-dot.status-skipped { background: hsl(var(--primary)); }
|
||||||
.title-dot.status-available { background: #d1d5db; }
|
.title-dot.status-available { background: #d1d5db; }
|
||||||
|
|
||||||
.title-text {
|
.title-text {
|
||||||
@@ -1090,27 +1090,27 @@ const getStatusColorClass = (status: string) => {
|
|||||||
|
|
||||||
.status-tag.status-success {
|
.status-tag.status-success {
|
||||||
background: #22c55e20;
|
background: #22c55e20;
|
||||||
color: #16a34a;
|
color: #22c55e;
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-tag.status-failed {
|
.status-tag.status-failed {
|
||||||
background: #ef444420;
|
background: #ef444420;
|
||||||
color: #dc2626;
|
color: #ef4444;
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-tag.status-cancelled {
|
.status-tag.status-cancelled {
|
||||||
background: #f59e0b20;
|
background: #f59e0b20;
|
||||||
color: #d97706;
|
color: #f59e0b;
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-tag.status-pending {
|
.status-tag.status-pending {
|
||||||
background: #3b82f620;
|
background: #3b82f620;
|
||||||
color: #2563eb;
|
color: #3b82f6;
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-tag.status-skipped {
|
.status-tag.status-skipped {
|
||||||
background: #1f293720;
|
background: hsl(var(--primary) / 0.15);
|
||||||
color: #1f2937;
|
color: hsl(var(--primary));
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-tag.status-available {
|
.status-tag.status-available {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ from sqlalchemy.orm import Session
|
|||||||
|
|
||||||
from src.api.base.admin_adapter import AdminApiAdapter
|
from src.api.base.admin_adapter import AdminApiAdapter
|
||||||
from src.api.base.models_service import invalidate_models_list_cache
|
from src.api.base.models_service import invalidate_models_list_cache
|
||||||
|
from src.services.cache.model_cache import ModelCacheService
|
||||||
from src.api.base.pipeline import ApiRequestPipeline
|
from src.api.base.pipeline import ApiRequestPipeline
|
||||||
from src.core.enums import ProviderBillingType
|
from src.core.enums import ProviderBillingType
|
||||||
from src.core.exceptions import InvalidRequestException, NotFoundException
|
from src.core.exceptions import InvalidRequestException, NotFoundException
|
||||||
@@ -373,6 +374,11 @@ class AdminUpdateProviderAdapter(AdminApiAdapter):
|
|||||||
# 清除 /v1/models 列表缓存(is_active 变更会影响模型可用性)
|
# 清除 /v1/models 列表缓存(is_active 变更会影响模型可用性)
|
||||||
await invalidate_models_list_cache()
|
await invalidate_models_list_cache()
|
||||||
|
|
||||||
|
# 如果更新了 is_active,清除 GlobalModel 解析缓存
|
||||||
|
# Provider 状态变更会影响模型解析结果
|
||||||
|
if "is_active" in update_data:
|
||||||
|
await ModelCacheService.invalidate_all_resolve_cache()
|
||||||
|
|
||||||
# 如果更新了 billing_type,清除缓存
|
# 如果更新了 billing_type,清除缓存
|
||||||
if "billing_type" in update_data:
|
if "billing_type" in update_data:
|
||||||
await ProviderCacheService.invalidate_provider_cache(provider.id)
|
await ProviderCacheService.invalidate_provider_cache(provider.id)
|
||||||
@@ -421,6 +427,9 @@ class AdminDeleteProviderAdapter(AdminApiAdapter):
|
|||||||
# 清除 /v1/models 列表缓存
|
# 清除 /v1/models 列表缓存
|
||||||
await invalidate_models_list_cache()
|
await invalidate_models_list_cache()
|
||||||
|
|
||||||
|
# 清除 GlobalModel 解析缓存(删除 Provider 会影响模型解析结果)
|
||||||
|
await ModelCacheService.invalidate_all_resolve_cache()
|
||||||
|
|
||||||
return {"message": "提供商已删除"}
|
return {"message": "提供商已删除"}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -350,8 +350,8 @@ def check_model_allowed_with_mappings(
|
|||||||
|
|
||||||
匹配优先级:
|
匹配优先级:
|
||||||
1. 精确匹配 model_name(用户请求的模型名,即 GlobalModel.name)
|
1. 精确匹配 model_name(用户请求的模型名,即 GlobalModel.name)
|
||||||
2. 精确匹配 candidate_models 中的任一模型名(Provider 的 provider_model_mappings)
|
2. 精确匹配 candidate_models ∩ allowed_models(Provider 支持且 Key 允许的模型名)
|
||||||
3. 遍历 model_mappings,检查每个映射是否匹配 allowed_models 中的任一项
|
3. 遍历 model_mappings 正则,检查 allowed_models 中是否有匹配项
|
||||||
|
|
||||||
映射匹配顺序说明:
|
映射匹配顺序说明:
|
||||||
- 按 allowed_models 集合的迭代顺序遍历(通常为字母顺序,因为内部使用 set)
|
- 按 allowed_models 集合的迭代顺序遍历(通常为字母顺序,因为内部使用 set)
|
||||||
@@ -362,8 +362,9 @@ def check_model_allowed_with_mappings(
|
|||||||
Args:
|
Args:
|
||||||
model_name: 请求的模型名称(GlobalModel.name)
|
model_name: 请求的模型名称(GlobalModel.name)
|
||||||
allowed_models: 允许的模型配置(来自 Provider Key)
|
allowed_models: 允许的模型配置(来自 Provider Key)
|
||||||
model_mappings: GlobalModel 的映射列表(来自 config.model_mappings)
|
model_mappings: GlobalModel 的映射列表(来自 config.model_mappings),支持正则表达式
|
||||||
candidate_models: 可选的候选模型集合(Provider 的 provider_model_names,包含 provider_model_name 和 provider_model_mappings)
|
candidate_models: 可选的候选模型集合(Provider 的 provider_model_names),
|
||||||
|
仅用于步骤 2 的精确匹配,不影响步骤 3 的正则匹配
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
(is_allowed, matched_model_name):
|
(is_allowed, matched_model_name):
|
||||||
@@ -400,13 +401,15 @@ def check_model_allowed_with_mappings(
|
|||||||
if not model_mappings:
|
if not model_mappings:
|
||||||
return False, None
|
return False, None
|
||||||
|
|
||||||
# 映射匹配的搜索空间:allowed_models ∩ candidate_models
|
# 正则映射匹配:直接在 allowed_models 上进行匹配
|
||||||
# 只在 Provider 实际支持的模型名中进行正则匹配,避免匹配到 Provider 不支持的模型
|
# GlobalModel.config.model_mappings 定义了"可以用哪些 Provider 模型名来提供服务"
|
||||||
if candidate_models is not None:
|
# 如果 Key 的 allowed_models 中有能被正则匹配的模型名,说明这个 Key 可以用于请求
|
||||||
allowed_set = allowed_set & candidate_models
|
#
|
||||||
if len(allowed_set) == 0:
|
# 注意:不再用 candidate_models 限制搜索空间
|
||||||
return False, None
|
# 原因:用户可能只配置了 GlobalModel 的正则映射规则,而没有在 Provider Model 的
|
||||||
|
# provider_model_mappings 中添加对应的模型名。正则映射的语义是"将请求重定向到匹配的模型名",
|
||||||
|
# 所以应该直接检查 Key 的 allowed_models 是否包含能被正则匹配的模型名。
|
||||||
|
#
|
||||||
# 遍历 allowed_set,检查是否有模型名能匹配 model_mappings 中的任一正则
|
# 遍历 allowed_set,检查是否有模型名能匹配 model_mappings 中的任一正则
|
||||||
# 排序确保确定性行为
|
# 排序确保确定性行为
|
||||||
for allowed_model in sorted(allowed_set):
|
for allowed_model in sorted(allowed_set):
|
||||||
|
|||||||
13
src/services/cache/model_cache.py
vendored
13
src/services/cache/model_cache.py
vendored
@@ -254,6 +254,19 @@ class ModelCacheService:
|
|||||||
logger.error(f"GlobalModel resolve 缓存清除失败,可能导致映射不一致: {e}")
|
logger.error(f"GlobalModel resolve 缓存清除失败,可能导致映射不一致: {e}")
|
||||||
logger.debug(f"GlobalModel 缓存已清除: {global_model_id}")
|
logger.debug(f"GlobalModel 缓存已清除: {global_model_id}")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def invalidate_all_resolve_cache() -> None:
|
||||||
|
"""
|
||||||
|
清除所有 GlobalModel 解析缓存
|
||||||
|
|
||||||
|
在 Provider 启用/禁用时调用,因为 Provider 状态变更会影响模型解析结果。
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
deleted = await CacheService.delete_pattern("global_model:resolve:*")
|
||||||
|
logger.debug(f"已清除 {deleted} 个 GlobalModel resolve 缓存")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"GlobalModel resolve 缓存清除失败: {e}")
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def resolve_global_model_by_name_or_mapping(
|
async def resolve_global_model_by_name_or_mapping(
|
||||||
db: Session, model_name: str
|
db: Session, model_name: str
|
||||||
|
|||||||
@@ -20,7 +20,8 @@ class TestCheckModelAllowedWithMappings:
|
|||||||
assert is_allowed is True
|
assert is_allowed is True
|
||||||
assert matched == "a"
|
assert matched == "a"
|
||||||
|
|
||||||
def test_mapping_match_respects_candidate_models(self) -> None:
|
def test_mapping_match_with_candidate_intersection(self) -> None:
|
||||||
|
"""当 candidate_models 和 allowed_models 有交集时,应优先返回交集中的模型"""
|
||||||
is_allowed, matched = check_model_allowed_with_mappings(
|
is_allowed, matched = check_model_allowed_with_mappings(
|
||||||
model_name="target",
|
model_name="target",
|
||||||
allowed_models=["other-1", "allowed-1"],
|
allowed_models=["other-1", "allowed-1"],
|
||||||
@@ -28,14 +29,29 @@ class TestCheckModelAllowedWithMappings:
|
|||||||
candidate_models={"allowed-1"},
|
candidate_models={"allowed-1"},
|
||||||
)
|
)
|
||||||
assert is_allowed is True
|
assert is_allowed is True
|
||||||
|
# 交集精确匹配优先于正则匹配
|
||||||
assert matched == "allowed-1"
|
assert matched == "allowed-1"
|
||||||
|
|
||||||
def test_mapping_match_candidate_models_no_intersection(self) -> None:
|
def test_mapping_match_without_candidate_intersection(self) -> None:
|
||||||
|
"""当 candidate_models 和 allowed_models 没有交集时,应继续尝试正则匹配"""
|
||||||
is_allowed, matched = check_model_allowed_with_mappings(
|
is_allowed, matched = check_model_allowed_with_mappings(
|
||||||
model_name="target",
|
model_name="target",
|
||||||
allowed_models=["allowed-1"],
|
allowed_models=["allowed-1"],
|
||||||
model_mappings=[r".*-1"],
|
model_mappings=[r".*-1"],
|
||||||
candidate_models={"not-present"},
|
candidate_models={"not-present"},
|
||||||
)
|
)
|
||||||
|
# 正则匹配应该成功,因为 allowed-1 匹配 .*-1
|
||||||
|
assert is_allowed is True
|
||||||
|
assert matched == "allowed-1"
|
||||||
|
|
||||||
|
def test_mapping_match_no_regex_match(self) -> None:
|
||||||
|
"""当正则不匹配 allowed_models 时,应返回 False"""
|
||||||
|
is_allowed, matched = check_model_allowed_with_mappings(
|
||||||
|
model_name="target",
|
||||||
|
allowed_models=["allowed-2"],
|
||||||
|
model_mappings=[r"other-.*"],
|
||||||
|
candidate_models={"not-present"},
|
||||||
|
)
|
||||||
|
# 正则 other-.* 不匹配 allowed-2
|
||||||
assert is_allowed is False
|
assert is_allowed is False
|
||||||
assert matched is None
|
assert matched is None
|
||||||
|
|||||||
Reference in New Issue
Block a user