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