mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 01:10:23 +08:00
refactor(model-permissions): 简化 allowed_models 为纯列表格式
移除按 API 格式区分的字典模式(Dict[str, List[str]]),统一使用简单列表格式。 - 删除 normalize_allowed_models 的 api_format 参数 - 删除 check_model_allowed 的 api_format 参数 - 简化 merge_allowed_models 为直接列表交集 - 移除前端的字典模式兼容代码和警告 UI - 删除 is_format_mode、convert_to_format_mode 等辅助函数
This commit is contained in:
@@ -82,31 +82,19 @@ export interface ProviderEndpoint {
|
||||
}
|
||||
|
||||
/**
|
||||
* 模型权限配置类型(支持简单列表和按格式字典两种模式)
|
||||
* 模型权限配置类型
|
||||
*
|
||||
* 使用示例:
|
||||
* 1. 不限制(允许所有模型): null
|
||||
* 2. 简单列表模式(所有 API 格式共享同一个白名单): ["gpt-4", "claude-3-opus"]
|
||||
* 3. 按格式字典模式(不同 API 格式使用不同的白名单):
|
||||
* { "OPENAI": ["gpt-4"], "CLAUDE": ["claude-3-opus"] }
|
||||
* 2. 白名单模式: ["gpt-4", "claude-3-opus"]
|
||||
*/
|
||||
export type AllowedModels = string[] | Record<string, string[]> | null
|
||||
export type AllowedModels = string[] | null
|
||||
|
||||
// AllowedModels 类型守卫函数
|
||||
export function isAllowedModelsList(value: AllowedModels): value is string[] {
|
||||
return Array.isArray(value)
|
||||
}
|
||||
|
||||
export function isAllowedModelsDict(value: AllowedModels): value is Record<string, string[]> {
|
||||
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return false
|
||||
}
|
||||
// 验证所有值都是字符串数组
|
||||
return Object.values(value).every(
|
||||
(v) => Array.isArray(v) && v.every((item) => typeof item === 'string')
|
||||
)
|
||||
}
|
||||
|
||||
export interface EndpointAPIKey {
|
||||
id: string
|
||||
provider_id: string
|
||||
@@ -119,7 +107,7 @@ export interface EndpointAPIKey {
|
||||
internal_priority: number // Key 内部优先级
|
||||
global_priority?: number | null // 全局 Key 优先级
|
||||
rpm_limit?: number | null // RPM 速率限制 (1-10000),null 表示自适应模式
|
||||
allowed_models?: AllowedModels // 允许使用的模型列表(null=不限制,列表=简单白名单,字典=按格式区分)
|
||||
allowed_models?: AllowedModels // 允许使用的模型列表(null=不限制)
|
||||
capabilities?: Record<string, boolean> | null // 能力标签配置(如 cache_1h, context_1m)
|
||||
// 缓存与熔断配置
|
||||
cache_ttl_minutes: number // 缓存 TTL(分钟),0=禁用
|
||||
|
||||
@@ -9,17 +9,6 @@
|
||||
>
|
||||
<template #default>
|
||||
<div class="space-y-4">
|
||||
<!-- 字典模式警告 -->
|
||||
<div
|
||||
v-if="isDictMode"
|
||||
class="rounded-lg border border-amber-500/50 bg-amber-50 dark:bg-amber-950/30 p-3"
|
||||
>
|
||||
<p class="text-sm text-amber-700 dark:text-amber-400">
|
||||
<strong>注意:</strong>此密钥使用按 API 格式区分的模型权限配置。
|
||||
编辑后将转换为统一列表模式,原有的格式区分信息将丢失。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 常驻选择面板 -->
|
||||
<div class="border rounded-lg overflow-hidden">
|
||||
<!-- 搜索 + 操作栏 -->
|
||||
@@ -374,9 +363,6 @@ const initialLockedModels = ref<string[]>([])
|
||||
// 所有添加过的自定义模型(包括已取消勾选的,保存前不消失)
|
||||
const allCustomModels = ref<string[]>([])
|
||||
|
||||
// 是否为字典模式(按 API 格式区分)
|
||||
const isDictMode = ref(false)
|
||||
|
||||
// 是否为自动获取模式
|
||||
const isAutoFetchMode = computed(() => props.apiKey?.auto_fetch_models ?? false)
|
||||
|
||||
@@ -646,20 +632,9 @@ async function fetchUpstreamModels() {
|
||||
// 解析 allowed_models
|
||||
function parseAllowedModels(allowed: AllowedModels): string[] {
|
||||
if (allowed === null || allowed === undefined) {
|
||||
isDictMode.value = false
|
||||
return []
|
||||
}
|
||||
if (Array.isArray(allowed)) {
|
||||
isDictMode.value = false
|
||||
return [...allowed]
|
||||
}
|
||||
// 字典模式:合并所有格式的模型,并设置警告标志
|
||||
isDictMode.value = true
|
||||
const all = new Set<string>()
|
||||
for (const models of Object.values(allowed)) {
|
||||
models.forEach(m => all.add(m))
|
||||
}
|
||||
return Array.from(all)
|
||||
return [...allowed]
|
||||
}
|
||||
|
||||
// 监听对话框打开
|
||||
|
||||
@@ -223,12 +223,10 @@ class AdminUpdateEndpointKeyAdapter(AdminApiAdapter):
|
||||
update_data["learned_rpm_limit"] = None
|
||||
logger.info("Key %s 切换为自适应 RPM 模式", self.key_id)
|
||||
|
||||
# 统一处理 allowed_models:空列表/空字典 -> None(表示不限制)
|
||||
# 统一处理 allowed_models:空列表 -> None(表示不限制)
|
||||
if "allowed_models" in update_data:
|
||||
am = update_data["allowed_models"]
|
||||
if am is not None and (
|
||||
(isinstance(am, list) and len(am) == 0) or (isinstance(am, dict) and len(am) == 0)
|
||||
):
|
||||
if isinstance(am, list) and len(am) == 0:
|
||||
update_data["allowed_models"] = None
|
||||
|
||||
# 统一处理 locked_models:空列表 -> None
|
||||
|
||||
@@ -310,19 +310,12 @@ class AdminGetModelRoutingPreviewAdapter(AdminApiAdapter):
|
||||
next_probe_at = fmt_next_probe
|
||||
|
||||
# 解析 allowed_models
|
||||
# 语义说明:
|
||||
# - None: 不限制(允许所有模型)
|
||||
# - {}: 空字典 = 不限制(normalize_allowed_models 返回 None)
|
||||
# - []: 空列表 = 拒绝所有模型
|
||||
# - {"CLAUDE": []}: 指定格式空列表 = 该格式拒绝所有
|
||||
raw_allowed_models = key.allowed_models
|
||||
if raw_allowed_models is None:
|
||||
allowed_models_list = None
|
||||
elif isinstance(raw_allowed_models, dict) and not raw_allowed_models:
|
||||
# 空 dict {} 在语义上等价于不限制
|
||||
allowed_models_list = None
|
||||
else:
|
||||
allowed_models_list = parse_allowed_models_to_list(raw_allowed_models)
|
||||
allowed_models_list = (
|
||||
parse_allowed_models_to_list(raw_allowed_models)
|
||||
if raw_allowed_models
|
||||
else None
|
||||
)
|
||||
|
||||
key_infos.append(
|
||||
RoutingKeyInfo(
|
||||
|
||||
@@ -352,7 +352,7 @@ def _get_available_model_ids_for_format(db: Session, api_formats: list[str]) ->
|
||||
if model_provider_id not in provider_ids_with_format:
|
||||
continue
|
||||
|
||||
# 检查该 provider 下是否有 Key 允许这个模型(支持 list/dict 两种 allowed_models)
|
||||
# 检查该 provider 下是否有 Key 允许这个模型
|
||||
from src.core.model_permissions import check_model_allowed
|
||||
|
||||
rules = provider_key_rules.get(model_provider_id, [])
|
||||
@@ -362,19 +362,14 @@ def _get_available_model_ids_for_format(db: Session, api_formats: list[str]) ->
|
||||
available_model_ids.add(model_id)
|
||||
break
|
||||
|
||||
# 对于支持多个格式的 Key:任意一个可用格式允许即可
|
||||
for fmt in usable_formats:
|
||||
if check_model_allowed(
|
||||
model_name=model_id,
|
||||
allowed_models=allowed_models, # type: ignore[arg-type]
|
||||
api_format=fmt,
|
||||
resolved_model_name=(model.provider_model_name if global_model else None),
|
||||
):
|
||||
available_model_ids.add(model_id)
|
||||
break
|
||||
else:
|
||||
continue
|
||||
break
|
||||
# 检查是否允许该模型
|
||||
if check_model_allowed(
|
||||
model_name=model_id,
|
||||
allowed_models=allowed_models, # type: ignore[arg-type]
|
||||
resolved_model_name=(model.provider_model_name if global_model else None),
|
||||
):
|
||||
available_model_ids.add(model_id)
|
||||
break
|
||||
|
||||
return available_model_ids
|
||||
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
"""
|
||||
模型权限工具
|
||||
|
||||
支持两种 allowed_models 格式:
|
||||
1. 简单模式(列表): ["claude-sonnet-4", "gpt-4o"]
|
||||
2. 按格式模式(字典): {"OPENAI": ["gpt-4o"], "CLAUDE": ["claude-sonnet-4"]}
|
||||
|
||||
allowed_models 格式: ["claude-sonnet-4", "gpt-4o"]
|
||||
使用 None/null 表示不限制(允许所有模型)
|
||||
|
||||
支持模型别名匹配:
|
||||
@@ -16,7 +13,7 @@
|
||||
|
||||
import re
|
||||
from functools import lru_cache
|
||||
from typing import Dict, List, Optional, Tuple, Union
|
||||
from typing import List, Optional, Tuple
|
||||
|
||||
import regex
|
||||
|
||||
@@ -29,19 +26,15 @@ MAX_MODEL_NAME_LENGTH = 200 # 与 MAX_ALIAS_LENGTH 保持一致
|
||||
REGEX_MATCH_TIMEOUT_MS = 100 # 正则匹配超时(毫秒)
|
||||
|
||||
# 类型别名
|
||||
AllowedModels = Optional[Union[List[str], Dict[str, List[str]]]]
|
||||
AllowedModels = Optional[List[str]]
|
||||
|
||||
|
||||
def normalize_allowed_models(
|
||||
allowed_models: AllowedModels,
|
||||
api_format: Optional[str] = None,
|
||||
) -> Optional[set[str]]:
|
||||
def normalize_allowed_models(allowed_models: AllowedModels) -> Optional[set[str]]:
|
||||
"""
|
||||
将 allowed_models 规范化为模型名称集合
|
||||
|
||||
Args:
|
||||
allowed_models: 允许的模型配置(列表或字典)
|
||||
api_format: 当前请求的 API 格式(用于字典模式)
|
||||
allowed_models: 允许的模型配置(列表)
|
||||
|
||||
Returns:
|
||||
- None: 不限制(允许所有模型)
|
||||
@@ -50,41 +43,12 @@ def normalize_allowed_models(
|
||||
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
|
||||
return set(allowed_models)
|
||||
|
||||
|
||||
def check_model_allowed(
|
||||
model_name: str,
|
||||
allowed_models: AllowedModels,
|
||||
api_format: Optional[str] = None,
|
||||
resolved_model_name: Optional[str] = None,
|
||||
) -> bool:
|
||||
"""
|
||||
@@ -93,14 +57,13 @@ def check_model_allowed(
|
||||
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)
|
||||
allowed_set = normalize_allowed_models(allowed_models)
|
||||
|
||||
if allowed_set is None:
|
||||
# 不限制
|
||||
@@ -130,8 +93,6 @@ def merge_allowed_models(
|
||||
规则:
|
||||
- 如果任一为 None,返回另一个
|
||||
- 如果都有值,取交集
|
||||
- 如果都是列表,取列表交集
|
||||
- 如果有字典,按 API 格式分别取交集(保持字典语义,不丢失格式区分信息)
|
||||
|
||||
Args:
|
||||
allowed_models_1: 第一个配置
|
||||
@@ -145,57 +106,8 @@ def merge_allowed_models(
|
||||
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
|
||||
intersection = set(allowed_models_1) & set(allowed_models_2)
|
||||
return sorted(intersection) if intersection else []
|
||||
|
||||
|
||||
def get_allowed_models_preview(
|
||||
@@ -215,19 +127,10 @@ def get_allowed_models_preview(
|
||||
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:
|
||||
if not allowed_models:
|
||||
return "(无)"
|
||||
|
||||
sorted_models = sorted(all_models)
|
||||
sorted_models = sorted(allowed_models)
|
||||
preview = ", ".join(sorted_models[:max_items])
|
||||
if len(sorted_models) > max_items:
|
||||
preview += f", ...共{len(sorted_models)}个"
|
||||
@@ -235,103 +138,20 @@ def get_allowed_models_preview(
|
||||
return preview
|
||||
|
||||
|
||||
def is_format_mode(allowed_models: AllowedModels) -> bool:
|
||||
def parse_allowed_models_to_list(allowed_models: AllowedModels) -> List[str]:
|
||||
"""
|
||||
判断 allowed_models 是否为按格式模式
|
||||
解析 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
|
||||
|
||||
|
||||
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 []
|
||||
return list(allowed_models)
|
||||
|
||||
|
||||
def validate_alias_pattern(pattern: str) -> Tuple[bool, Optional[str]]:
|
||||
@@ -530,7 +350,6 @@ def match_model_with_pattern(pattern: str, model_name: str) -> bool:
|
||||
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,
|
||||
candidate_models: Optional[set[str]] = None,
|
||||
@@ -552,7 +371,6 @@ def check_model_allowed_with_aliases(
|
||||
Args:
|
||||
model_name: 请求的模型名称
|
||||
allowed_models: 允许的模型配置(来自 Provider Key)
|
||||
api_format: 当前请求的 API 格式
|
||||
resolved_model_name: 解析后的 GlobalModel.name
|
||||
model_aliases: GlobalModel 的别名列表(来自 config.model_aliases)
|
||||
candidate_models: 可选的候选模型集合(用于限制别名匹配只能落到这些模型名上)
|
||||
@@ -563,7 +381,7 @@ def check_model_allowed_with_aliases(
|
||||
- matched_model_name: 通过别名匹配到的模型名(仅别名匹配时有值,精确匹配时为 None)
|
||||
"""
|
||||
# 先尝试精确匹配(使用原有逻辑)
|
||||
if check_model_allowed(model_name, allowed_models, api_format, resolved_model_name):
|
||||
if check_model_allowed(model_name, allowed_models, resolved_model_name):
|
||||
return True, None
|
||||
|
||||
# 如果精确匹配失败且有别名配置,尝试别名匹配
|
||||
@@ -571,7 +389,7 @@ def check_model_allowed_with_aliases(
|
||||
return False, None
|
||||
|
||||
# 获取 allowed_models 的集合
|
||||
allowed_set = normalize_allowed_models(allowed_models, api_format)
|
||||
allowed_set = normalize_allowed_models(allowed_models)
|
||||
if allowed_set is None:
|
||||
# 不限制,已在 check_model_allowed 中返回 True
|
||||
return True, None
|
||||
|
||||
@@ -4,7 +4,7 @@ ProviderEndpoint 相关的 API 模型定义
|
||||
|
||||
import re
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional, Union
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
|
||||
@@ -145,9 +145,9 @@ class EndpointAPIKeyCreate(BaseModel):
|
||||
rpm_limit: Optional[int] = Field(
|
||||
default=None, ge=1, le=10000, description="RPM 限制(NULL=自适应模式)"
|
||||
)
|
||||
allowed_models: Optional[Union[List[str], Dict[str, List[str]]]] = Field(
|
||||
allowed_models: Optional[List[str]] = Field(
|
||||
default=None,
|
||||
description="允许使用的模型列表(null=不限制,列表=简单白名单,字典=按API格式区分)",
|
||||
description="允许使用的模型列表(null=不限制)",
|
||||
)
|
||||
|
||||
# 能力标签
|
||||
@@ -200,67 +200,27 @@ class EndpointAPIKeyCreate(BaseModel):
|
||||
|
||||
@field_validator("allowed_models")
|
||||
@classmethod
|
||||
def validate_allowed_models(
|
||||
cls, v: Optional[Union[List[str], Dict[str, List[str]]]]
|
||||
) -> Optional[Union[List[str], Dict[str, List[str]]]]:
|
||||
def validate_allowed_models(cls, v: Optional[List[str]]) -> Optional[List[str]]:
|
||||
"""
|
||||
规范化 allowed_models:
|
||||
- 列表模式:去空、去重、保留顺序
|
||||
- 字典模式:key 统一大写(支持 "*"),value 去空、去重、保留顺序
|
||||
规范化 allowed_models:去空、去重、保留顺序
|
||||
"""
|
||||
if v is None:
|
||||
return v
|
||||
|
||||
if isinstance(v, list):
|
||||
cleaned: List[str] = []
|
||||
seen: set[str] = set()
|
||||
for item in v:
|
||||
if not isinstance(item, str):
|
||||
raise ValueError("allowed_models 列表必须为字符串数组")
|
||||
name = item.strip()
|
||||
if not name or name in seen:
|
||||
continue
|
||||
seen.add(name)
|
||||
cleaned.append(name)
|
||||
return cleaned
|
||||
if not isinstance(v, list):
|
||||
raise ValueError("allowed_models 必须是列表")
|
||||
|
||||
if isinstance(v, dict):
|
||||
from src.core.enums import APIFormat
|
||||
|
||||
allowed_formats = {fmt.value for fmt in APIFormat}
|
||||
normalized: Dict[str, List[str]] = {}
|
||||
for raw_key, models in v.items():
|
||||
if not isinstance(raw_key, str):
|
||||
raise ValueError("allowed_models 字典的 key 必须为字符串")
|
||||
|
||||
key = raw_key.upper()
|
||||
if key != "*" and key not in allowed_formats:
|
||||
raise ValueError(
|
||||
f"allowed_models 字典的 key 必须是 {sorted(allowed_formats)} 或 '*',当前值: {raw_key}"
|
||||
)
|
||||
|
||||
if models is None:
|
||||
# null 表示该格式不限制,跳过(不加入字典)
|
||||
continue
|
||||
if not isinstance(models, list):
|
||||
raise ValueError("allowed_models 字典的 value 必须为字符串数组")
|
||||
|
||||
cleaned: List[str] = []
|
||||
seen: set[str] = set()
|
||||
for item in models:
|
||||
if not isinstance(item, str):
|
||||
raise ValueError("allowed_models 字典的 value 必须为字符串数组")
|
||||
name = item.strip()
|
||||
if not name or name in seen:
|
||||
continue
|
||||
seen.add(name)
|
||||
cleaned.append(name)
|
||||
|
||||
normalized[key] = cleaned
|
||||
|
||||
return normalized
|
||||
|
||||
raise ValueError("allowed_models 必须是列表或字典")
|
||||
cleaned: List[str] = []
|
||||
seen: set[str] = set()
|
||||
for item in v:
|
||||
if not isinstance(item, str):
|
||||
raise ValueError("allowed_models 列表元素必须为字符串")
|
||||
name = item.strip()
|
||||
if not name or name in seen:
|
||||
continue
|
||||
seen.add(name)
|
||||
cleaned.append(name)
|
||||
return cleaned
|
||||
|
||||
@field_validator("api_key")
|
||||
@classmethod
|
||||
@@ -330,9 +290,9 @@ class EndpointAPIKeyUpdate(BaseModel):
|
||||
rpm_limit: Optional[int] = Field(
|
||||
default=None, ge=1, le=10000, description="RPM 限制(null=自适应模式)"
|
||||
)
|
||||
allowed_models: Optional[Union[List[str], Dict[str, List[str]]]] = Field(
|
||||
allowed_models: Optional[List[str]] = Field(
|
||||
default=None,
|
||||
description="允许使用的模型列表(null=不限制,列表=简单白名单,字典=按API格式区分)",
|
||||
description="允许使用的模型列表(null=不限制)",
|
||||
)
|
||||
capabilities: Optional[Dict[str, bool]] = Field(
|
||||
default=None, description="Key 能力标签,如 {'cache_1h': true, 'context_1m': true}"
|
||||
@@ -376,9 +336,7 @@ class EndpointAPIKeyUpdate(BaseModel):
|
||||
|
||||
@field_validator("allowed_models")
|
||||
@classmethod
|
||||
def validate_allowed_models(
|
||||
cls, v: Optional[Union[List[str], Dict[str, List[str]]]]
|
||||
) -> Optional[Union[List[str], Dict[str, List[str]]]]:
|
||||
def validate_allowed_models(cls, v: Optional[List[str]]) -> Optional[List[str]]:
|
||||
# 与 EndpointAPIKeyCreate 保持一致
|
||||
return EndpointAPIKeyCreate.validate_allowed_models(v)
|
||||
|
||||
@@ -450,7 +408,7 @@ class EndpointAPIKeyResponse(BaseModel):
|
||||
internal_priority: int = Field(default=50, description="Endpoint 内部优先级")
|
||||
global_priority: Optional[int] = Field(default=None, description="全局 Key 优先级")
|
||||
rpm_limit: Optional[int] = None
|
||||
allowed_models: Optional[Union[List[str], Dict[str, List[str]]]] = None
|
||||
allowed_models: Optional[List[str]] = None
|
||||
capabilities: Optional[Dict[str, bool]] = Field(default=None, description="Key 能力标签")
|
||||
|
||||
# 缓存与熔断配置
|
||||
|
||||
9
src/services/cache/aware_scheduler.py
vendored
9
src/services/cache/aware_scheduler.py
vendored
@@ -524,8 +524,7 @@ class CacheAwareScheduler:
|
||||
user_api_key.allowed_providers, user.allowed_providers if user else None
|
||||
)
|
||||
|
||||
# 合并 allowed_models
|
||||
# allowed_models 支持 list/dict 两种结构,不能转成 set 否则会导致权限校验失效
|
||||
# 合并 allowed_models(取交集)
|
||||
from src.core.model_permissions import merge_allowed_models
|
||||
|
||||
result["allowed_models"] = merge_allowed_models(
|
||||
@@ -612,13 +611,12 @@ class CacheAwareScheduler:
|
||||
)
|
||||
return [], global_model_id
|
||||
|
||||
# 0.2 检查模型是否被允许(支持简单列表和按格式字典两种模式)
|
||||
# 0.2 检查模型是否被允许
|
||||
from src.core.model_permissions import check_model_allowed, get_allowed_models_preview
|
||||
|
||||
if not check_model_allowed(
|
||||
model_name=requested_model_name,
|
||||
allowed_models=allowed_models,
|
||||
api_format=target_format.value,
|
||||
resolved_model_name=resolved_model_name,
|
||||
):
|
||||
resolved_note = (
|
||||
@@ -915,7 +913,7 @@ class CacheAwareScheduler:
|
||||
if not is_available:
|
||||
return False, circuit_reason or "熔断器已打开", None
|
||||
|
||||
# 模型权限检查:使用 allowed_models 白名单(支持简单列表和按格式字典两种模式)
|
||||
# 模型权限检查:使用 allowed_models 白名单
|
||||
# None = 允许所有模型,[] = 拒绝所有模型,["a","b"] = 只允许指定模型
|
||||
# 支持通配符别名匹配(通过 model_aliases)
|
||||
from src.core.model_permissions import (
|
||||
@@ -927,7 +925,6 @@ class CacheAwareScheduler:
|
||||
is_allowed, alias_matched_model = check_model_allowed_with_aliases(
|
||||
model_name=model_name,
|
||||
allowed_models=key.allowed_models,
|
||||
api_format=api_format,
|
||||
resolved_model_name=resolved_model_name,
|
||||
model_aliases=model_aliases,
|
||||
candidate_models=candidate_models,
|
||||
|
||||
@@ -6,7 +6,6 @@ class TestCheckModelAllowedWithAliases:
|
||||
is_allowed, matched = check_model_allowed_with_aliases(
|
||||
model_name="gpt-4o",
|
||||
allowed_models=["gpt-4o"],
|
||||
api_format="OPENAI",
|
||||
resolved_model_name="gpt-4o",
|
||||
model_aliases=[r"gpt-4o-.*"],
|
||||
)
|
||||
@@ -17,7 +16,6 @@ class TestCheckModelAllowedWithAliases:
|
||||
is_allowed, matched = check_model_allowed_with_aliases(
|
||||
model_name="target",
|
||||
allowed_models=["b", "a"],
|
||||
api_format="OPENAI",
|
||||
resolved_model_name="target",
|
||||
model_aliases=[r".*"],
|
||||
)
|
||||
@@ -28,7 +26,6 @@ class TestCheckModelAllowedWithAliases:
|
||||
is_allowed, matched = check_model_allowed_with_aliases(
|
||||
model_name="target",
|
||||
allowed_models=["other-1", "allowed-1"],
|
||||
api_format="OPENAI",
|
||||
resolved_model_name="target",
|
||||
model_aliases=[r".*-1"],
|
||||
candidate_models={"allowed-1"},
|
||||
@@ -40,11 +37,9 @@ class TestCheckModelAllowedWithAliases:
|
||||
is_allowed, matched = check_model_allowed_with_aliases(
|
||||
model_name="target",
|
||||
allowed_models=["allowed-1"],
|
||||
api_format="OPENAI",
|
||||
resolved_model_name="target",
|
||||
model_aliases=[r".*-1"],
|
||||
candidate_models={"not-present"},
|
||||
)
|
||||
assert is_allowed is False
|
||||
assert matched is None
|
||||
|
||||
|
||||
Reference in New Issue
Block a user