mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-03 09:50:21 +08:00
refactor: 将模型别名(alias)统一重命名为模型映射(mapping)
- 重命名 API: alias-mapping-preview -> mapping-preview - 重命名类型: AliasMatchedModel -> MappingMatchedModel 等 - 重命名字段: model_aliases -> model_mappings, alias_matched_model -> mapping_matched_model - 重命名前端组件: ModelAliasesTab -> ModelMappingsTab - 重命名验证函数: validate_model_aliases -> validate_model_mappings - 同步更新相关测试用例 - 补充 ModelService 中新增/批量创建模型时的缓存失效逻辑
This commit is contained in:
@@ -97,34 +97,34 @@ export async function testModel(data: TestModelRequest): Promise<TestModelRespon
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 别名映射预览相关类型
|
* 映射预览相关类型
|
||||||
*/
|
*/
|
||||||
export interface AliasMatchedModel {
|
export interface MappingMatchedModel {
|
||||||
allowed_model: string
|
allowed_model: string
|
||||||
alias_pattern: string
|
mapping_pattern: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AliasMatchingGlobalModel {
|
export interface MappingMatchingGlobalModel {
|
||||||
global_model_id: string
|
global_model_id: string
|
||||||
global_model_name: string
|
global_model_name: string
|
||||||
display_name: string
|
display_name: string
|
||||||
is_active: boolean
|
is_active: boolean
|
||||||
matched_models: AliasMatchedModel[]
|
matched_models: MappingMatchedModel[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AliasMatchingKey {
|
export interface MappingMatchingKey {
|
||||||
key_id: string
|
key_id: string
|
||||||
key_name: string
|
key_name: string
|
||||||
masked_key: string
|
masked_key: string
|
||||||
is_active: boolean
|
is_active: boolean
|
||||||
allowed_models: string[]
|
allowed_models: string[]
|
||||||
matching_global_models: AliasMatchingGlobalModel[]
|
matching_global_models: MappingMatchingGlobalModel[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ProviderAliasMappingPreviewResponse {
|
export interface ProviderMappingPreviewResponse {
|
||||||
provider_id: string
|
provider_id: string
|
||||||
provider_name: string
|
provider_name: string
|
||||||
keys: AliasMatchingKey[]
|
keys: MappingMatchingKey[]
|
||||||
total_keys: number
|
total_keys: number
|
||||||
total_matches: number
|
total_matches: number
|
||||||
// 截断提示
|
// 截断提示
|
||||||
@@ -134,11 +134,11 @@ export interface ProviderAliasMappingPreviewResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取 Provider 别名映射预览
|
* 获取 Provider 映射预览
|
||||||
*/
|
*/
|
||||||
export async function getProviderAliasMappingPreview(
|
export async function getProviderMappingPreview(
|
||||||
providerId: string
|
providerId: string
|
||||||
): Promise<ProviderAliasMappingPreviewResponse> {
|
): Promise<ProviderMappingPreviewResponse> {
|
||||||
const response = await client.get(`/api/admin/providers/${providerId}/alias-mapping-preview`)
|
const response = await client.get(`/api/admin/providers/${providerId}/mapping-preview`)
|
||||||
return response.data
|
return response.data
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -108,11 +108,11 @@
|
|||||||
type="button"
|
type="button"
|
||||||
class="flex-1 px-2 sm:px-4 py-2 text-xs sm:text-sm font-medium rounded-md transition-all duration-200"
|
class="flex-1 px-2 sm:px-4 py-2 text-xs sm:text-sm font-medium rounded-md transition-all duration-200"
|
||||||
:class="[
|
:class="[
|
||||||
detailTab === 'aliases'
|
detailTab === 'mappings'
|
||||||
? 'bg-primary text-primary-foreground shadow-sm'
|
? 'bg-primary text-primary-foreground shadow-sm'
|
||||||
: 'text-muted-foreground hover:text-foreground hover:bg-background/50'
|
: 'text-muted-foreground hover:text-foreground hover:bg-background/50'
|
||||||
]"
|
]"
|
||||||
@click="detailTab = 'aliases'"
|
@click="detailTab = 'mappings'"
|
||||||
>
|
>
|
||||||
<span class="hidden sm:inline">模型映射</span>
|
<span class="hidden sm:inline">模型映射</span>
|
||||||
<span class="sm:hidden">映射</span>
|
<span class="sm:hidden">映射</span>
|
||||||
@@ -434,13 +434,13 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Tab 3: 模型映射 -->
|
<!-- Tab 3: 模型映射 -->
|
||||||
<div v-show="detailTab === 'aliases'">
|
<div v-show="detailTab === 'mappings'">
|
||||||
<ModelAliasesTab
|
<ModelMappingsTab
|
||||||
v-if="model"
|
v-if="model"
|
||||||
:global-model-id="model.id"
|
:global-model-id="model.id"
|
||||||
:model-name="model.name"
|
:model-name="model.name"
|
||||||
:aliases="model.config?.model_aliases || []"
|
:mappings="model.config?.model_mappings || []"
|
||||||
@update="handleAliasesUpdate"
|
@update="handleMappingsUpdate"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -480,7 +480,7 @@ import TableRow from '@/components/ui/table-row.vue'
|
|||||||
import TableHead from '@/components/ui/table-head.vue'
|
import TableHead from '@/components/ui/table-head.vue'
|
||||||
import TableCell from '@/components/ui/table-cell.vue'
|
import TableCell from '@/components/ui/table-cell.vue'
|
||||||
import RoutingTab from './RoutingTab.vue'
|
import RoutingTab from './RoutingTab.vue'
|
||||||
import ModelAliasesTab from './ModelAliasesTab.vue'
|
import ModelMappingsTab from './ModelMappingsTab.vue'
|
||||||
|
|
||||||
// 使用外部类型定义
|
// 使用外部类型定义
|
||||||
import type { GlobalModelResponse } from '@/api/global-models'
|
import type { GlobalModelResponse } from '@/api/global-models'
|
||||||
@@ -543,9 +543,9 @@ function refreshRoutingData() {
|
|||||||
routingTabRef.value?.loadRoutingData?.()
|
routingTabRef.value?.loadRoutingData?.()
|
||||||
}
|
}
|
||||||
|
|
||||||
// 处理模型别名更新
|
// 处理模型映射更新
|
||||||
function handleAliasesUpdate(_aliases: string[]) {
|
function handleMappingsUpdate(_mappings: string[]) {
|
||||||
// 别名已在 ModelAliasesTab 内部保存到服务器
|
// 映射已在 ModelMappingsTab 内部保存到服务器
|
||||||
// 刷新路由数据以反映可能的候选变化
|
// 刷新路由数据以反映可能的候选变化
|
||||||
refreshRoutingData()
|
refreshRoutingData()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
<div class="flex items-baseline gap-2">
|
<div class="flex items-baseline gap-2">
|
||||||
<h4 class="text-sm font-semibold">映射规则</h4>
|
<h4 class="text-sm font-semibold">映射规则</h4>
|
||||||
<span class="text-xs text-muted-foreground">
|
<span class="text-xs text-muted-foreground">
|
||||||
支持正则表达式 ({{ localAliases.length }}/{{ MAX_ALIASES_PER_MODEL }})
|
支持正则表达式 ({{ localMappings.length }}/{{ MAX_MAPPINGS_PER_MODEL }})
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-1">
|
<div class="flex items-center gap-1">
|
||||||
@@ -15,8 +15,8 @@
|
|||||||
size="icon"
|
size="icon"
|
||||||
class="h-7 w-7"
|
class="h-7 w-7"
|
||||||
title="添加规则"
|
title="添加规则"
|
||||||
:disabled="localAliases.length >= MAX_ALIASES_PER_MODEL"
|
:disabled="localMappings.length >= MAX_MAPPINGS_PER_MODEL"
|
||||||
@click="addAlias"
|
@click="addMapping"
|
||||||
>
|
>
|
||||||
<Plus class="w-4 h-4" />
|
<Plus class="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -35,9 +35,9 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 规则列表 -->
|
<!-- 规则列表 -->
|
||||||
<div v-if="localAliases.length > 0" class="divide-y">
|
<div v-if="localMappings.length > 0" class="divide-y">
|
||||||
<div
|
<div
|
||||||
v-for="(alias, index) in localAliases"
|
v-for="(mapping, index) in localMappings"
|
||||||
:key="index"
|
:key="index"
|
||||||
>
|
>
|
||||||
<!-- 规则行 -->
|
<!-- 规则行 -->
|
||||||
@@ -51,31 +51,31 @@
|
|||||||
/>
|
/>
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
<Input
|
<Input
|
||||||
v-model="localAliases[index]"
|
v-model="localMappings[index]"
|
||||||
placeholder="例如: claude-haiku-.*"
|
placeholder="例如: claude-haiku-.*"
|
||||||
:class="`font-mono text-sm ${alias.trim() && !getAliasValidation(alias).valid ? 'border-destructive' : ''}`"
|
:class="`font-mono text-sm ${mapping.trim() && !getMappingValidation(mapping).valid ? 'border-destructive' : ''}`"
|
||||||
@click.stop
|
@click.stop
|
||||||
@input="markDirty"
|
@input="markDirty"
|
||||||
/>
|
/>
|
||||||
<!-- 验证错误提示 -->
|
<!-- 验证错误提示 -->
|
||||||
<div
|
<div
|
||||||
v-if="alias.trim() && !getAliasValidation(alias).valid"
|
v-if="mapping.trim() && !getMappingValidation(mapping).valid"
|
||||||
class="flex items-center gap-1 mt-1 text-xs text-destructive"
|
class="flex items-center gap-1 mt-1 text-xs text-destructive"
|
||||||
>
|
>
|
||||||
<AlertCircle class="w-3 h-3" />
|
<AlertCircle class="w-3 h-3" />
|
||||||
<span>{{ getAliasValidation(alias).error }}</span>
|
<span>{{ getMappingValidation(mapping).error }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- 匹配统计 -->
|
<!-- 匹配统计 -->
|
||||||
<Badge
|
<Badge
|
||||||
v-if="getAliasValidation(alias).valid && getMatchCount(alias) > 0"
|
v-if="getMappingValidation(mapping).valid && getMatchCount(mapping) > 0"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
class="text-xs flex-shrink-0 h-6 leading-none"
|
class="text-xs flex-shrink-0 h-6 leading-none"
|
||||||
>
|
>
|
||||||
{{ getMatchCount(alias) }} 匹配
|
{{ getMatchCount(mapping) }} 匹配
|
||||||
</Badge>
|
</Badge>
|
||||||
<Badge
|
<Badge
|
||||||
v-else-if="alias.trim() && getAliasValidation(alias).valid"
|
v-else-if="mapping.trim() && getMappingValidation(mapping).valid"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
class="text-xs text-muted-foreground flex-shrink-0 h-6 leading-none"
|
class="text-xs text-muted-foreground flex-shrink-0 h-6 leading-none"
|
||||||
>
|
>
|
||||||
@@ -90,7 +90,7 @@
|
|||||||
class="h-7 w-7 text-muted-foreground hover:text-primary"
|
class="h-7 w-7 text-muted-foreground hover:text-primary"
|
||||||
title="保存"
|
title="保存"
|
||||||
:disabled="saving || hasValidationErrors"
|
:disabled="saving || hasValidationErrors"
|
||||||
@click.stop="saveAliases"
|
@click.stop="saveMappings"
|
||||||
>
|
>
|
||||||
<Save v-if="!saving" class="w-4 h-4" />
|
<Save v-if="!saving" class="w-4 h-4" />
|
||||||
<RefreshCw v-else class="w-4 h-4 animate-spin" />
|
<RefreshCw v-else class="w-4 h-4 animate-spin" />
|
||||||
@@ -101,7 +101,7 @@
|
|||||||
class="h-7 w-7 text-muted-foreground hover:text-destructive"
|
class="h-7 w-7 text-muted-foreground hover:text-destructive"
|
||||||
title="删除"
|
title="删除"
|
||||||
:disabled="saving"
|
:disabled="saving"
|
||||||
@click.stop="removeAlias(index)"
|
@click.stop="removeMapping(index)"
|
||||||
>
|
>
|
||||||
<Trash2 class="w-4 h-4" />
|
<Trash2 class="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -117,15 +117,15 @@
|
|||||||
<RefreshCw class="w-4 h-4 animate-spin text-muted-foreground" />
|
<RefreshCw class="w-4 h-4 animate-spin text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else-if="getMatchedKeysForAlias(alias).length === 0" class="text-center py-4">
|
<div v-else-if="getMatchedKeysForMapping(mapping).length === 0" class="text-center py-4">
|
||||||
<p class="text-sm text-muted-foreground">
|
<p class="text-sm text-muted-foreground">
|
||||||
{{ alias.trim() ? '此规则暂无匹配的 Key 白名单' : '请输入别名规则' }}
|
{{ mapping.trim() ? '此规则暂无匹配的 Key 白名单' : '请输入映射规则' }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="space-y-2">
|
<div v-else class="space-y-2">
|
||||||
<div
|
<div
|
||||||
v-for="item in getMatchedKeysForAlias(alias)"
|
v-for="item in getMatchedKeysForMapping(mapping)"
|
||||||
:key="item.keyId"
|
:key="item.keyId"
|
||||||
class="bg-background rounded-md border p-3"
|
class="bg-background rounded-md border p-3"
|
||||||
>
|
>
|
||||||
@@ -159,10 +159,10 @@
|
|||||||
>
|
>
|
||||||
<GitMerge class="w-10 h-10 mx-auto text-muted-foreground/30 mb-3" />
|
<GitMerge class="w-10 h-10 mx-auto text-muted-foreground/30 mb-3" />
|
||||||
<p class="text-sm text-muted-foreground">
|
<p class="text-sm text-muted-foreground">
|
||||||
暂无别名规则
|
暂无映射规则
|
||||||
</p>
|
</p>
|
||||||
<p class="text-xs text-muted-foreground mt-1">
|
<p class="text-xs text-muted-foreground mt-1">
|
||||||
添加别名可匹配 Provider Key 白名单中的模型
|
添加映射可匹配 Provider Key 白名单中的模型
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -180,16 +180,16 @@ import { useToast } from '@/composables/useToast'
|
|||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
globalModelId: string
|
globalModelId: string
|
||||||
modelName: string
|
modelName: string
|
||||||
aliases: string[]
|
mappings: string[]
|
||||||
loading?: boolean
|
loading?: boolean
|
||||||
}>()
|
}>()
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
update: [aliases: string[]]
|
update: [mappings: string[]]
|
||||||
refresh: []
|
refresh: []
|
||||||
}>()
|
}>()
|
||||||
// 安全限制常量(与后端保持一致)
|
// 安全限制常量(与后端保持一致)
|
||||||
const MAX_ALIASES_PER_MODEL = 50
|
const MAX_MAPPINGS_PER_MODEL = 50
|
||||||
const MAX_ALIAS_LENGTH = 200
|
const MAX_MAPPING_LENGTH = 200
|
||||||
|
|
||||||
// 危险的正则模式(可能导致 ReDoS,与后端 model_permissions.py 保持一致)
|
// 危险的正则模式(可能导致 ReDoS,与后端 model_permissions.py 保持一致)
|
||||||
// 注意:这些是用于检测用户输入字符串中的危险正则构造
|
// 注意:这些是用于检测用户输入字符串中的危险正则构造
|
||||||
@@ -222,8 +222,8 @@ const REGEX_MATCH_MAX_INPUT_LENGTH = 200
|
|||||||
const { success: toastSuccess, error: toastError } = useToast()
|
const { success: toastSuccess, error: toastError } = useToast()
|
||||||
|
|
||||||
// 本地状态
|
// 本地状态
|
||||||
const localAliases = ref<string[]>([...props.aliases])
|
const localMappings = ref<string[]>([...props.mappings])
|
||||||
const originalAliases = ref<string[]>([...props.aliases]) // 用于保存失败时恢复
|
const originalMappings = ref<string[]>([...props.mappings]) // 用于保存失败时恢复
|
||||||
const isDirty = ref(false)
|
const isDirty = ref(false)
|
||||||
const saving = ref(false)
|
const saving = ref(false)
|
||||||
const expandedIndex = ref<number | null>(null)
|
const expandedIndex = ref<number | null>(null)
|
||||||
@@ -277,7 +277,7 @@ class LRURegexCache {
|
|||||||
|
|
||||||
const regexCache = new LRURegexCache(REGEX_CACHE_MAX_SIZE)
|
const regexCache = new LRURegexCache(REGEX_CACHE_MAX_SIZE)
|
||||||
|
|
||||||
interface MatchedKeyForAlias {
|
interface MatchedKeyForMapping {
|
||||||
keyId: string
|
keyId: string
|
||||||
keyName: string
|
keyName: string
|
||||||
maskedKey: string
|
maskedKey: string
|
||||||
@@ -291,15 +291,15 @@ interface ValidationResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 验证别名规则是否安全
|
* 验证映射规则是否安全
|
||||||
*/
|
*/
|
||||||
function validateAliasPattern(pattern: string): ValidationResult {
|
function validateMappingPattern(pattern: string): ValidationResult {
|
||||||
if (!pattern || !pattern.trim()) {
|
if (!pattern || !pattern.trim()) {
|
||||||
return { valid: false, error: '规则不能为空' }
|
return { valid: false, error: '规则不能为空' }
|
||||||
}
|
}
|
||||||
|
|
||||||
if (pattern.length > MAX_ALIAS_LENGTH) {
|
if (pattern.length > MAX_MAPPING_LENGTH) {
|
||||||
return { valid: false, error: `规则过长 (最大 ${MAX_ALIAS_LENGTH} 字符)` }
|
return { valid: false, error: `规则过长 (最大 ${MAX_MAPPING_LENGTH} 字符)` }
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查危险模式
|
// 检查危险模式
|
||||||
@@ -320,22 +320,22 @@ function validateAliasPattern(pattern: string): ValidationResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取别名的验证状态
|
* 获取映射的验证状态
|
||||||
*/
|
*/
|
||||||
function getAliasValidation(alias: string): ValidationResult {
|
function getMappingValidation(mapping: string): ValidationResult {
|
||||||
if (!alias.trim()) {
|
if (!mapping.trim()) {
|
||||||
return { valid: true } // 空值暂不报错,保存时过滤
|
return { valid: true } // 空值暂不报错,保存时过滤
|
||||||
}
|
}
|
||||||
return validateAliasPattern(alias)
|
return validateMappingPattern(mapping)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 检查是否有验证错误
|
* 检查是否有验证错误
|
||||||
*/
|
*/
|
||||||
const hasValidationErrors = computed(() => {
|
const hasValidationErrors = computed(() => {
|
||||||
return localAliases.value.some(alias => {
|
return localMappings.value.some(mapping => {
|
||||||
if (!alias.trim()) return false
|
if (!mapping.trim()) return false
|
||||||
return !validateAliasPattern(alias).valid
|
return !validateMappingPattern(mapping).valid
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -349,7 +349,7 @@ function matchPattern(pattern: string, text: string): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 长度检查
|
// 长度检查
|
||||||
if (pattern.length > MAX_ALIAS_LENGTH) {
|
if (pattern.length > MAX_MAPPING_LENGTH) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -385,12 +385,12 @@ function matchPattern(pattern: string, text: string): boolean {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取指定别名匹配的 Key 列表
|
// 获取指定映射匹配的 Key 列表
|
||||||
function getMatchedKeysForAlias(alias: string): MatchedKeyForAlias[] {
|
function getMatchedKeysForMapping(mapping: string): MatchedKeyForMapping[] {
|
||||||
if (!routingData.value || !alias.trim()) return []
|
if (!routingData.value || !mapping.trim()) return []
|
||||||
|
|
||||||
// 使用 Map 按 keyId 去重并合并匹配结果
|
// 使用 Map 按 keyId 去重并合并匹配结果
|
||||||
const keyMap = new Map<string, MatchedKeyForAlias>()
|
const keyMap = new Map<string, MatchedKeyForMapping>()
|
||||||
|
|
||||||
for (const provider of routingData.value.providers) {
|
for (const provider of routingData.value.providers) {
|
||||||
for (const endpoint of provider.endpoints) {
|
for (const endpoint of provider.endpoints) {
|
||||||
@@ -399,7 +399,7 @@ function getMatchedKeysForAlias(alias: string): MatchedKeyForAlias[] {
|
|||||||
|
|
||||||
const matchedModels: string[] = []
|
const matchedModels: string[] = []
|
||||||
for (const allowedModel of key.allowed_models) {
|
for (const allowedModel of key.allowed_models) {
|
||||||
if (matchPattern(alias, allowedModel)) {
|
if (matchPattern(mapping, allowedModel)) {
|
||||||
matchedModels.push(allowedModel)
|
matchedModels.push(allowedModel)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -427,18 +427,18 @@ function getMatchedKeysForAlias(alias: string): MatchedKeyForAlias[] {
|
|||||||
return Array.from(keyMap.values())
|
return Array.from(keyMap.values())
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取指定别名的匹配数量
|
// 获取指定映射的匹配数量
|
||||||
function getMatchCount(alias: string): number {
|
function getMatchCount(mapping: string): number {
|
||||||
return getMatchedKeysForAlias(alias).reduce((sum, item) => sum + item.matchedModels.length, 0)
|
return getMatchedKeysForMapping(mapping).reduce((sum, item) => sum + item.matchedModels.length, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleExpand(index: number) {
|
function toggleExpand(index: number) {
|
||||||
expandedIndex.value = expandedIndex.value === index ? null : index
|
expandedIndex.value = expandedIndex.value === index ? null : index
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(() => props.aliases, (newAliases) => {
|
watch(() => props.mappings, (newAliases) => {
|
||||||
localAliases.value = [...newAliases]
|
localMappings.value = [...newAliases]
|
||||||
originalAliases.value = [...newAliases]
|
originalMappings.value = [...newAliases]
|
||||||
isDirty.value = false
|
isDirty.value = false
|
||||||
}, { deep: true })
|
}, { deep: true })
|
||||||
|
|
||||||
@@ -452,18 +452,18 @@ function markDirty() {
|
|||||||
isDirty.value = true
|
isDirty.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
function addAlias() {
|
function addMapping() {
|
||||||
if (localAliases.value.length >= MAX_ALIASES_PER_MODEL) {
|
if (localMappings.value.length >= MAX_MAPPINGS_PER_MODEL) {
|
||||||
toastError(`最多支持 ${MAX_ALIASES_PER_MODEL} 条别名规则`)
|
toastError(`最多支持 ${MAX_MAPPINGS_PER_MODEL} 条映射规则`)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
localAliases.value.push('')
|
localMappings.value.push('')
|
||||||
isDirty.value = true
|
isDirty.value = true
|
||||||
expandedIndex.value = localAliases.value.length - 1
|
expandedIndex.value = localMappings.value.length - 1
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeAlias(index: number) {
|
function removeMapping(index: number) {
|
||||||
localAliases.value.splice(index, 1)
|
localMappings.value.splice(index, 1)
|
||||||
isDirty.value = true
|
isDirty.value = true
|
||||||
if (expandedIndex.value === index) {
|
if (expandedIndex.value === index) {
|
||||||
expandedIndex.value = null
|
expandedIndex.value = null
|
||||||
@@ -472,8 +472,8 @@ function removeAlias(index: number) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveAliases() {
|
async function saveMappings() {
|
||||||
const cleanedAliases = localAliases.value
|
const cleanedMappings = localMappings.value
|
||||||
.map(a => a.trim())
|
.map(a => a.trim())
|
||||||
.filter(a => a.length > 0)
|
.filter(a => a.length > 0)
|
||||||
|
|
||||||
@@ -484,28 +484,28 @@ async function saveAliases() {
|
|||||||
|
|
||||||
const updatedConfig = {
|
const updatedConfig = {
|
||||||
...currentConfig,
|
...currentConfig,
|
||||||
model_aliases: cleanedAliases.length > 0 ? cleanedAliases : undefined,
|
model_mappings: cleanedMappings.length > 0 ? cleanedMappings : undefined,
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!updatedConfig.model_aliases || updatedConfig.model_aliases.length === 0) {
|
if (!updatedConfig.model_mappings || updatedConfig.model_mappings.length === 0) {
|
||||||
delete updatedConfig.model_aliases
|
delete updatedConfig.model_mappings
|
||||||
}
|
}
|
||||||
|
|
||||||
await updateGlobalModel(props.globalModelId, {
|
await updateGlobalModel(props.globalModelId, {
|
||||||
config: updatedConfig,
|
config: updatedConfig,
|
||||||
})
|
})
|
||||||
|
|
||||||
localAliases.value = cleanedAliases
|
localMappings.value = cleanedMappings
|
||||||
originalAliases.value = [...cleanedAliases] // 更新原始值
|
originalMappings.value = [...cleanedMappings] // 更新原始值
|
||||||
isDirty.value = false
|
isDirty.value = false
|
||||||
|
|
||||||
toastSuccess('别名规则已保存')
|
toastSuccess('映射规则已保存')
|
||||||
emit('update', cleanedAliases)
|
emit('update', cleanedMappings)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
log.error('保存别名规则失败:', err)
|
log.error('保存映射规则失败:', err)
|
||||||
toastError('保存失败,请重试')
|
toastError('保存失败,请重试')
|
||||||
// 保存失败时恢复到原始值
|
// 保存失败时恢复到原始值
|
||||||
localAliases.value = [...originalAliases.value]
|
localMappings.value = [...originalMappings.value]
|
||||||
isDirty.value = false
|
isDirty.value = false
|
||||||
} finally {
|
} finally {
|
||||||
saving.value = false
|
saving.value = false
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
export { default as GlobalModelFormDialog } from './GlobalModelFormDialog.vue'
|
export { default as GlobalModelFormDialog } from './GlobalModelFormDialog.vue'
|
||||||
export { default as ModelDetailDrawer } from './ModelDetailDrawer.vue'
|
export { default as ModelDetailDrawer } from './ModelDetailDrawer.vue'
|
||||||
export { default as TieredPricingEditor } from './TieredPricingEditor.vue'
|
export { default as TieredPricingEditor } from './TieredPricingEditor.vue'
|
||||||
export { default as ModelAliasesTab } from './ModelAliasesTab.vue'
|
export { default as ModelMappingsTab } from './ModelMappingsTab.vue'
|
||||||
|
|||||||
@@ -287,11 +287,11 @@ import ModelMappingDialog, { type AliasGroup } from '../ModelMappingDialog.vue'
|
|||||||
import { useToast } from '@/composables/useToast'
|
import { useToast } from '@/composables/useToast'
|
||||||
import {
|
import {
|
||||||
getProviderModels,
|
getProviderModels,
|
||||||
getProviderAliasMappingPreview,
|
getProviderMappingPreview,
|
||||||
testModel,
|
testModel,
|
||||||
type Model,
|
type Model,
|
||||||
type ProviderModelAlias,
|
type ProviderModelAlias,
|
||||||
type ProviderAliasMappingPreviewResponse
|
type ProviderMappingPreviewResponse
|
||||||
} from '@/api/endpoints'
|
} from '@/api/endpoints'
|
||||||
import { updateModel } from '@/api/endpoints/models'
|
import { updateModel } from '@/api/endpoints/models'
|
||||||
import { parseTestModelError } from '@/utils/errorParser'
|
import { parseTestModelError } from '@/utils/errorParser'
|
||||||
@@ -333,7 +333,7 @@ const { error: showError, success: showSuccess } = useToast()
|
|||||||
// 状态
|
// 状态
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const models = ref<Model[]>([])
|
const models = ref<Model[]>([])
|
||||||
const aliasMappingPreview = ref<ProviderAliasMappingPreviewResponse | null>(null)
|
const aliasMappingPreview = ref<ProviderMappingPreviewResponse | null>(null)
|
||||||
const dialogOpen = ref(false)
|
const dialogOpen = ref(false)
|
||||||
const deleteConfirmOpen = ref(false)
|
const deleteConfirmOpen = ref(false)
|
||||||
const editingGroup = ref<AliasGroup | null>(null)
|
const editingGroup = ref<AliasGroup | null>(null)
|
||||||
@@ -410,7 +410,7 @@ const regexMappings = computed<CombinedMapping[]>(() => {
|
|||||||
// 添加 Key 信息
|
// 添加 Key 信息
|
||||||
const keyMatches: MappingItem[] = gm.matched_models.map(m => ({
|
const keyMatches: MappingItem[] = gm.matched_models.map(m => ({
|
||||||
name: m.allowed_model,
|
name: m.allowed_model,
|
||||||
pattern: m.alias_pattern
|
pattern: m.mapping_pattern
|
||||||
}))
|
}))
|
||||||
|
|
||||||
mapping.matchedKeys!.push({
|
mapping.matchedKeys!.push({
|
||||||
@@ -425,7 +425,7 @@ const regexMappings = computed<CombinedMapping[]>(() => {
|
|||||||
if (!mapping.mappings.some(m => m.name === match.allowed_model)) {
|
if (!mapping.mappings.some(m => m.name === match.allowed_model)) {
|
||||||
mapping.mappings.push({
|
mapping.mappings.push({
|
||||||
name: match.allowed_model,
|
name: match.allowed_model,
|
||||||
pattern: match.alias_pattern
|
pattern: match.mapping_pattern
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -472,7 +472,7 @@ async function loadData() {
|
|||||||
loading.value = true
|
loading.value = true
|
||||||
const [modelsData, previewData] = await Promise.all([
|
const [modelsData, previewData] = await Promise.all([
|
||||||
getProviderModels(props.provider.id),
|
getProviderModels(props.provider.id),
|
||||||
getProviderAliasMappingPreview(props.provider.id).catch(() => null)
|
getProviderMappingPreview(props.provider.id).catch(() => null)
|
||||||
])
|
])
|
||||||
models.value = modelsData
|
models.value = modelsData
|
||||||
aliasMappingPreview.value = previewData
|
aliasMappingPreview.value = previewData
|
||||||
|
|||||||
@@ -322,12 +322,12 @@ class AdminCreateGlobalModelAdapter(AdminApiAdapter):
|
|||||||
|
|
||||||
async def handle(self, context): # type: ignore[override]
|
async def handle(self, context): # type: ignore[override]
|
||||||
from src.core.exceptions import InvalidRequestException
|
from src.core.exceptions import InvalidRequestException
|
||||||
from src.core.model_permissions import validate_and_extract_model_aliases
|
from src.core.model_permissions import validate_and_extract_model_mappings
|
||||||
|
|
||||||
# 验证 model_aliases(如果有)
|
# 验证 model_mappings(如果有)
|
||||||
is_valid, error, _ = validate_and_extract_model_aliases(self.payload.config)
|
is_valid, error, _ = validate_and_extract_model_mappings(self.payload.config)
|
||||||
if not is_valid:
|
if not is_valid:
|
||||||
raise InvalidRequestException(f"别名规则验证失败: {error}", "model_aliases")
|
raise InvalidRequestException(f"映射规则验证失败: {error}", "model_mappings")
|
||||||
|
|
||||||
# 将 TieredPricingConfig 转换为 dict
|
# 将 TieredPricingConfig 转换为 dict
|
||||||
tiered_pricing_dict = self.payload.default_tiered_pricing.model_dump()
|
tiered_pricing_dict = self.payload.default_tiered_pricing.model_dump()
|
||||||
@@ -361,12 +361,12 @@ class AdminUpdateGlobalModelAdapter(AdminApiAdapter):
|
|||||||
|
|
||||||
async def handle(self, context): # type: ignore[override]
|
async def handle(self, context): # type: ignore[override]
|
||||||
from src.core.exceptions import InvalidRequestException
|
from src.core.exceptions import InvalidRequestException
|
||||||
from src.core.model_permissions import validate_and_extract_model_aliases
|
from src.core.model_permissions import validate_and_extract_model_mappings
|
||||||
|
|
||||||
# 验证 model_aliases(如果有)
|
# 验证 model_mappings(如果有)
|
||||||
is_valid, error, _ = validate_and_extract_model_aliases(self.payload.config)
|
is_valid, error, _ = validate_and_extract_model_mappings(self.payload.config)
|
||||||
if not is_valid:
|
if not is_valid:
|
||||||
raise InvalidRequestException(f"别名规则验证失败: {error}", "model_aliases")
|
raise InvalidRequestException(f"映射规则验证失败: {error}", "model_mappings")
|
||||||
|
|
||||||
# 使用行级锁获取旧的 GlobalModel 信息,防止并发更新导致的竞态条件
|
# 使用行级锁获取旧的 GlobalModel 信息,防止并发更新导致的竞态条件
|
||||||
# 设置 2 秒锁超时,允许短暂等待而非立即失败,提升并发操作的成功率
|
# 设置 2 秒锁超时,允许短暂等待而非立即失败,提升并发操作的成功率
|
||||||
|
|||||||
@@ -405,6 +405,7 @@ class AdminCreateProviderModelAdapter(AdminApiAdapter):
|
|||||||
try:
|
try:
|
||||||
model = ModelService.create_model(db, self.provider_id, self.model_data)
|
model = ModelService.create_model(db, self.provider_id, self.model_data)
|
||||||
logger.info(f"Model created: {model.provider_model_name} for provider {provider.name} by {context.user.username}")
|
logger.info(f"Model created: {model.provider_model_name} for provider {provider.name} by {context.user.username}")
|
||||||
|
# 缓存失效已在 ModelService.create_model 中处理
|
||||||
return ModelService.convert_to_response(model)
|
return ModelService.convert_to_response(model)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
raise InvalidRequestException(str(exc))
|
raise InvalidRequestException(str(exc))
|
||||||
@@ -447,6 +448,7 @@ class AdminUpdateProviderModelAdapter(AdminApiAdapter):
|
|||||||
try:
|
try:
|
||||||
updated_model = ModelService.update_model(db, self.model_id, self.model_data)
|
updated_model = ModelService.update_model(db, self.model_id, self.model_data)
|
||||||
logger.info(f"Model updated: {updated_model.provider_model_name} by {context.user.username}")
|
logger.info(f"Model updated: {updated_model.provider_model_name} by {context.user.username}")
|
||||||
|
# 缓存失效已在 ModelService.update_model 中处理
|
||||||
return ModelService.convert_to_response(updated_model)
|
return ModelService.convert_to_response(updated_model)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
raise InvalidRequestException(str(exc))
|
raise InvalidRequestException(str(exc))
|
||||||
@@ -471,6 +473,7 @@ class AdminDeleteProviderModelAdapter(AdminApiAdapter):
|
|||||||
try:
|
try:
|
||||||
ModelService.delete_model(db, self.model_id)
|
ModelService.delete_model(db, self.model_id)
|
||||||
logger.info(f"Model deleted: {model_name} by {context.user.username}")
|
logger.info(f"Model deleted: {model_name} by {context.user.username}")
|
||||||
|
# 缓存失效已在 ModelService.delete_model 中处理
|
||||||
return {"message": f"Model '{model_name}' deleted successfully"}
|
return {"message": f"Model '{model_name}' deleted successfully"}
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
raise InvalidRequestException(str(exc))
|
raise InvalidRequestException(str(exc))
|
||||||
@@ -490,6 +493,7 @@ class AdminBatchCreateModelsAdapter(AdminApiAdapter):
|
|||||||
try:
|
try:
|
||||||
models = ModelService.batch_create_models(db, self.provider_id, self.models_data)
|
models = ModelService.batch_create_models(db, self.provider_id, self.models_data)
|
||||||
logger.info(f"Batch created {len(models)} models for provider {provider.name} by {context.user.username}")
|
logger.info(f"Batch created {len(models)} models for provider {provider.name} by {context.user.username}")
|
||||||
|
# 缓存失效已在 ModelService.batch_create_models 中处理
|
||||||
return [ModelService.convert_to_response(model) for model in models]
|
return [ModelService.convert_to_response(model) for model in models]
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
raise InvalidRequestException(str(exc))
|
raise InvalidRequestException(str(exc))
|
||||||
@@ -633,6 +637,11 @@ class AdminBatchAssignModelsToProviderAdapter(AdminApiAdapter):
|
|||||||
|
|
||||||
# 清除 /v1/models 列表缓存
|
# 清除 /v1/models 列表缓存
|
||||||
if success:
|
if success:
|
||||||
|
# Provider 新增模型实现后,清除同进程的 ModelMapper 缓存,避免 TTL 内仍返回 None
|
||||||
|
from src.services.cache.invalidation import get_cache_invalidation_service
|
||||||
|
cache_service = get_cache_invalidation_service()
|
||||||
|
cache_service.on_model_changed(self.provider_id, success[0].get("global_model_id", ""))
|
||||||
|
|
||||||
await invalidate_models_list_cache()
|
await invalidate_models_list_cache()
|
||||||
|
|
||||||
return BatchAssignModelsToProviderResponse(success=success, errors=errors)
|
return BatchAssignModelsToProviderResponse(success=success, errors=errors)
|
||||||
|
|||||||
@@ -23,58 +23,58 @@ router = APIRouter(tags=["Provider CRUD"])
|
|||||||
pipeline = ApiRequestPipeline()
|
pipeline = ApiRequestPipeline()
|
||||||
|
|
||||||
|
|
||||||
# 别名映射预览配置(管理后台功能,限制宽松)
|
# 映射预览配置(管理后台功能,限制宽松)
|
||||||
ALIAS_PREVIEW_MAX_KEYS = 200
|
MAPPING_PREVIEW_MAX_KEYS = 200
|
||||||
ALIAS_PREVIEW_MAX_MODELS = 500
|
MAPPING_PREVIEW_MAX_MODELS = 500
|
||||||
ALIAS_PREVIEW_TIMEOUT_SECONDS = 10.0
|
MAPPING_PREVIEW_TIMEOUT_SECONDS = 10.0
|
||||||
|
|
||||||
|
|
||||||
# ========== Response Models ==========
|
# ========== Response Models ==========
|
||||||
|
|
||||||
|
|
||||||
class AliasMatchedModel(BaseModel):
|
class MappingMatchedModel(BaseModel):
|
||||||
"""匹配到的模型名称"""
|
"""匹配到的模型名称"""
|
||||||
|
|
||||||
allowed_model: str = Field(..., description="Key 白名单中匹配到的模型名")
|
allowed_model: str = Field(..., description="Key 白名单中匹配到的模型名")
|
||||||
alias_pattern: str = Field(..., description="匹配的别名规则")
|
mapping_pattern: str = Field(..., description="匹配的映射规则")
|
||||||
|
|
||||||
|
|
||||||
class AliasMatchingGlobalModel(BaseModel):
|
class MappingMatchingGlobalModel(BaseModel):
|
||||||
"""有别名匹配的 GlobalModel"""
|
"""有映射匹配的 GlobalModel"""
|
||||||
|
|
||||||
global_model_id: str
|
global_model_id: str
|
||||||
global_model_name: str
|
global_model_name: str
|
||||||
display_name: str
|
display_name: str
|
||||||
is_active: bool
|
is_active: bool
|
||||||
matched_models: List[AliasMatchedModel] = Field(
|
matched_models: List[MappingMatchedModel] = Field(
|
||||||
default_factory=list, description="匹配到的模型列表"
|
default_factory=list, description="匹配到的模型列表"
|
||||||
)
|
)
|
||||||
|
|
||||||
model_config = ConfigDict(from_attributes=True)
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
|
||||||
class AliasMatchingKey(BaseModel):
|
class MappingMatchingKey(BaseModel):
|
||||||
"""有别名匹配的 Key"""
|
"""有映射匹配的 Key"""
|
||||||
|
|
||||||
key_id: str
|
key_id: str
|
||||||
key_name: str
|
key_name: str
|
||||||
masked_key: str
|
masked_key: str
|
||||||
is_active: bool
|
is_active: bool
|
||||||
allowed_models: List[str] = Field(default_factory=list, description="Key 的模型白名单")
|
allowed_models: List[str] = Field(default_factory=list, description="Key 的模型白名单")
|
||||||
matching_global_models: List[AliasMatchingGlobalModel] = Field(
|
matching_global_models: List[MappingMatchingGlobalModel] = Field(
|
||||||
default_factory=list, description="匹配到的 GlobalModel 列表"
|
default_factory=list, description="匹配到的 GlobalModel 列表"
|
||||||
)
|
)
|
||||||
|
|
||||||
model_config = ConfigDict(from_attributes=True)
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
|
||||||
class ProviderAliasMappingPreviewResponse(BaseModel):
|
class ProviderMappingPreviewResponse(BaseModel):
|
||||||
"""Provider 别名映射预览响应"""
|
"""Provider 映射预览响应"""
|
||||||
|
|
||||||
provider_id: str
|
provider_id: str
|
||||||
provider_name: str
|
provider_name: str
|
||||||
keys: List[AliasMatchingKey] = Field(
|
keys: List[MappingMatchingKey] = Field(
|
||||||
default_factory=list, description="有白名单配置且匹配到别名的 Key 列表"
|
default_factory=list, description="有白名单配置且匹配到映射的 Key 列表"
|
||||||
)
|
)
|
||||||
total_keys: int = Field(0, description="有匹配结果的 Key 数量")
|
total_keys: int = Field(0, description="有匹配结果的 Key 数量")
|
||||||
total_matches: int = Field(
|
total_matches: int = Field(
|
||||||
@@ -417,18 +417,18 @@ class AdminDeleteProviderAdapter(AdminApiAdapter):
|
|||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/{provider_id}/alias-mapping-preview",
|
"/{provider_id}/mapping-preview",
|
||||||
response_model=ProviderAliasMappingPreviewResponse,
|
response_model=ProviderMappingPreviewResponse,
|
||||||
)
|
)
|
||||||
async def get_provider_alias_mapping_preview(
|
async def get_provider_mapping_preview(
|
||||||
request: Request,
|
request: Request,
|
||||||
provider_id: str,
|
provider_id: str,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
) -> ProviderAliasMappingPreviewResponse:
|
) -> ProviderMappingPreviewResponse:
|
||||||
"""
|
"""
|
||||||
获取 Provider 别名映射预览
|
获取 Provider 映射预览
|
||||||
|
|
||||||
查看该 Provider 的 Key 白名单能够被哪些 GlobalModel 的别名规则匹配。
|
查看该 Provider 的 Key 白名单能够被哪些 GlobalModel 的映射规则匹配。
|
||||||
|
|
||||||
**路径参数**:
|
**路径参数**:
|
||||||
- `provider_id`: Provider ID
|
- `provider_id`: Provider ID
|
||||||
@@ -445,26 +445,26 @@ async def get_provider_alias_mapping_preview(
|
|||||||
- `total_keys`: 有白名单配置的 Key 总数
|
- `total_keys`: 有白名单配置的 Key 总数
|
||||||
- `total_matches`: 匹配到的 GlobalModel 总数
|
- `total_matches`: 匹配到的 GlobalModel 总数
|
||||||
"""
|
"""
|
||||||
adapter = AdminGetProviderAliasMappingPreviewAdapter(provider_id=provider_id)
|
adapter = AdminGetProviderMappingPreviewAdapter(provider_id=provider_id)
|
||||||
|
|
||||||
# 添加超时保护,防止复杂匹配导致的 DoS
|
# 添加超时保护,防止复杂匹配导致的 DoS
|
||||||
try:
|
try:
|
||||||
return await asyncio.wait_for(
|
return await asyncio.wait_for(
|
||||||
pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode),
|
pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode),
|
||||||
timeout=ALIAS_PREVIEW_TIMEOUT_SECONDS,
|
timeout=MAPPING_PREVIEW_TIMEOUT_SECONDS,
|
||||||
)
|
)
|
||||||
except asyncio.TimeoutError:
|
except asyncio.TimeoutError:
|
||||||
logger.warning(f"别名映射预览超时: provider_id={provider_id}")
|
logger.warning(f"映射预览超时: provider_id={provider_id}")
|
||||||
raise InvalidRequestException("别名映射预览超时,请简化配置或稍后重试")
|
raise InvalidRequestException("映射预览超时,请简化配置或稍后重试")
|
||||||
|
|
||||||
|
|
||||||
class AdminGetProviderAliasMappingPreviewAdapter(AdminApiAdapter):
|
class AdminGetProviderMappingPreviewAdapter(AdminApiAdapter):
|
||||||
"""获取 Provider 别名映射预览"""
|
"""获取 Provider 映射预览"""
|
||||||
|
|
||||||
def __init__(self, provider_id: str):
|
def __init__(self, provider_id: str):
|
||||||
self.provider_id = provider_id
|
self.provider_id = provider_id
|
||||||
|
|
||||||
async def handle(self, context) -> ProviderAliasMappingPreviewResponse: # type: ignore[override]
|
async def handle(self, context) -> ProviderMappingPreviewResponse: # type: ignore[override]
|
||||||
db = context.db
|
db = context.db
|
||||||
|
|
||||||
# 获取 Provider
|
# 获取 Provider
|
||||||
@@ -502,27 +502,27 @@ class AdminGetProviderAliasMappingPreviewAdapter(AdminApiAdapter):
|
|||||||
ProviderAPIKey.provider_id == self.provider_id,
|
ProviderAPIKey.provider_id == self.provider_id,
|
||||||
ProviderAPIKey.allowed_models.isnot(None),
|
ProviderAPIKey.allowed_models.isnot(None),
|
||||||
)
|
)
|
||||||
.limit(ALIAS_PREVIEW_MAX_KEYS)
|
.limit(MAPPING_PREVIEW_MAX_KEYS)
|
||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
|
|
||||||
# 计算被截断的 Key 数量
|
# 计算被截断的 Key 数量
|
||||||
if total_keys_with_allowed_models > ALIAS_PREVIEW_MAX_KEYS:
|
if total_keys_with_allowed_models > MAPPING_PREVIEW_MAX_KEYS:
|
||||||
truncated_keys = total_keys_with_allowed_models - ALIAS_PREVIEW_MAX_KEYS
|
truncated_keys = total_keys_with_allowed_models - MAPPING_PREVIEW_MAX_KEYS
|
||||||
|
|
||||||
# 获取有 model_aliases 配置的 GlobalModel 总数(用于截断统计)
|
# 获取有 model_mappings 配置的 GlobalModel 总数(用于截断统计)
|
||||||
total_models_with_aliases = (
|
total_models_with_mappings = (
|
||||||
db.query(func.count(GlobalModel.id))
|
db.query(func.count(GlobalModel.id))
|
||||||
.filter(
|
.filter(
|
||||||
GlobalModel.config.isnot(None),
|
GlobalModel.config.isnot(None),
|
||||||
GlobalModel.config["model_aliases"].isnot(None),
|
GlobalModel.config["model_mappings"].isnot(None),
|
||||||
func.jsonb_array_length(GlobalModel.config["model_aliases"]) > 0,
|
func.jsonb_array_length(GlobalModel.config["model_mappings"]) > 0,
|
||||||
)
|
)
|
||||||
.scalar()
|
.scalar()
|
||||||
or 0
|
or 0
|
||||||
)
|
)
|
||||||
|
|
||||||
# 只查询有 model_aliases 配置的 GlobalModel(使用 SQLAlchemy JSONB 操作符)
|
# 只查询有 model_mappings 配置的 GlobalModel(使用 SQLAlchemy JSONB 操作符)
|
||||||
global_models = (
|
global_models = (
|
||||||
db.query(
|
db.query(
|
||||||
GlobalModel.id,
|
GlobalModel.id,
|
||||||
@@ -533,28 +533,28 @@ class AdminGetProviderAliasMappingPreviewAdapter(AdminApiAdapter):
|
|||||||
)
|
)
|
||||||
.filter(
|
.filter(
|
||||||
GlobalModel.config.isnot(None),
|
GlobalModel.config.isnot(None),
|
||||||
GlobalModel.config["model_aliases"].isnot(None),
|
GlobalModel.config["model_mappings"].isnot(None),
|
||||||
func.jsonb_array_length(GlobalModel.config["model_aliases"]) > 0,
|
func.jsonb_array_length(GlobalModel.config["model_mappings"]) > 0,
|
||||||
)
|
)
|
||||||
.limit(ALIAS_PREVIEW_MAX_MODELS)
|
.limit(MAPPING_PREVIEW_MAX_MODELS)
|
||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
|
|
||||||
# 计算被截断的 GlobalModel 数量
|
# 计算被截断的 GlobalModel 数量
|
||||||
if total_models_with_aliases > ALIAS_PREVIEW_MAX_MODELS:
|
if total_models_with_mappings > MAPPING_PREVIEW_MAX_MODELS:
|
||||||
truncated_models = total_models_with_aliases - ALIAS_PREVIEW_MAX_MODELS
|
truncated_models = total_models_with_mappings - MAPPING_PREVIEW_MAX_MODELS
|
||||||
|
|
||||||
# 构建有别名配置的 GlobalModel 映射
|
# 构建有映射配置的 GlobalModel 映射
|
||||||
models_with_aliases: Dict[str, tuple] = {} # id -> (model_info, aliases)
|
models_with_mappings: Dict[str, tuple] = {} # id -> (model_info, mappings)
|
||||||
for gm in global_models:
|
for gm in global_models:
|
||||||
config = gm.config or {}
|
config = gm.config or {}
|
||||||
aliases = config.get("model_aliases", [])
|
mappings = config.get("model_mappings", [])
|
||||||
if aliases:
|
if mappings:
|
||||||
models_with_aliases[gm.id] = (gm, aliases)
|
models_with_mappings[gm.id] = (gm, mappings)
|
||||||
|
|
||||||
# 如果没有任何带别名的 GlobalModel,直接返回空结果
|
# 如果没有任何带映射的 GlobalModel,直接返回空结果
|
||||||
if not models_with_aliases:
|
if not models_with_mappings:
|
||||||
return ProviderAliasMappingPreviewResponse(
|
return ProviderMappingPreviewResponse(
|
||||||
provider_id=provider.id,
|
provider_id=provider.id,
|
||||||
provider_name=provider.name,
|
provider_name=provider.name,
|
||||||
keys=[],
|
keys=[],
|
||||||
@@ -565,7 +565,7 @@ class AdminGetProviderAliasMappingPreviewAdapter(AdminApiAdapter):
|
|||||||
truncated_models=0,
|
truncated_models=0,
|
||||||
)
|
)
|
||||||
|
|
||||||
key_infos: List[AliasMatchingKey] = []
|
key_infos: List[MappingMatchingKey] = []
|
||||||
total_matches = 0
|
total_matches = 0
|
||||||
|
|
||||||
# 创建 CryptoService 实例
|
# 创建 CryptoService 实例
|
||||||
@@ -591,25 +591,25 @@ class AdminGetProviderAliasMappingPreviewAdapter(AdminApiAdapter):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
# 查找匹配的 GlobalModel
|
# 查找匹配的 GlobalModel
|
||||||
matching_global_models: List[AliasMatchingGlobalModel] = []
|
matching_global_models: List[MappingMatchingGlobalModel] = []
|
||||||
|
|
||||||
for gm_id, (gm, aliases) in models_with_aliases.items():
|
for gm_id, (gm, mappings) in models_with_mappings.items():
|
||||||
matched_models: List[AliasMatchedModel] = []
|
matched_models: List[MappingMatchedModel] = []
|
||||||
|
|
||||||
for allowed_model in allowed_models_list:
|
for allowed_model in allowed_models_list:
|
||||||
for alias_pattern in aliases:
|
for mapping_pattern in mappings:
|
||||||
if match_model_with_pattern(alias_pattern, allowed_model):
|
if match_model_with_pattern(mapping_pattern, allowed_model):
|
||||||
matched_models.append(
|
matched_models.append(
|
||||||
AliasMatchedModel(
|
MappingMatchedModel(
|
||||||
allowed_model=allowed_model,
|
allowed_model=allowed_model,
|
||||||
alias_pattern=alias_pattern,
|
mapping_pattern=mapping_pattern,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
break # 一个 allowed_model 只需匹配一个别名
|
break # 一个 allowed_model 只需匹配一个映射
|
||||||
|
|
||||||
if matched_models:
|
if matched_models:
|
||||||
matching_global_models.append(
|
matching_global_models.append(
|
||||||
AliasMatchingGlobalModel(
|
MappingMatchingGlobalModel(
|
||||||
global_model_id=gm.id,
|
global_model_id=gm.id,
|
||||||
global_model_name=gm.name,
|
global_model_name=gm.name,
|
||||||
display_name=gm.display_name,
|
display_name=gm.display_name,
|
||||||
@@ -621,7 +621,7 @@ class AdminGetProviderAliasMappingPreviewAdapter(AdminApiAdapter):
|
|||||||
|
|
||||||
if matching_global_models:
|
if matching_global_models:
|
||||||
key_infos.append(
|
key_infos.append(
|
||||||
AliasMatchingKey(
|
MappingMatchingKey(
|
||||||
key_id=key.id or "",
|
key_id=key.id or "",
|
||||||
key_name=key.name or "",
|
key_name=key.name or "",
|
||||||
masked_key=masked_key,
|
masked_key=masked_key,
|
||||||
@@ -633,7 +633,7 @@ class AdminGetProviderAliasMappingPreviewAdapter(AdminApiAdapter):
|
|||||||
|
|
||||||
is_truncated = truncated_keys > 0 or truncated_models > 0
|
is_truncated = truncated_keys > 0 or truncated_models > 0
|
||||||
|
|
||||||
return ProviderAliasMappingPreviewResponse(
|
return ProviderMappingPreviewResponse(
|
||||||
provider_id=provider.id,
|
provider_id=provider.id,
|
||||||
provider_name=provider.name,
|
provider_name=provider.name,
|
||||||
keys=key_infos,
|
keys=key_infos,
|
||||||
|
|||||||
@@ -429,8 +429,8 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
|
|||||||
provider_api_format=str(endpoint.api_format) if endpoint.api_format else None,
|
provider_api_format=str(endpoint.api_format) if endpoint.api_format else None,
|
||||||
)
|
)
|
||||||
|
|
||||||
# 获取模型映射(优先使用别名匹配到的模型,其次是 Provider 级别的映射)
|
# 获取模型映射(优先使用映射匹配到的模型,其次是 Provider 级别的映射)
|
||||||
mapped_model = candidate.alias_matched_model if candidate else None
|
mapped_model = candidate.mapping_matched_model if candidate else None
|
||||||
if not mapped_model:
|
if not mapped_model:
|
||||||
mapped_model = await self._get_mapped_model(
|
mapped_model = await self._get_mapped_model(
|
||||||
source_model=ctx.model,
|
source_model=ctx.model,
|
||||||
@@ -663,8 +663,8 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
|
|||||||
|
|
||||||
provider_name = str(provider.name)
|
provider_name = str(provider.name)
|
||||||
|
|
||||||
# 获取模型映射(优先使用别名匹配到的模型,其次是 Provider 级别的映射)
|
# 获取模型映射(优先使用映射匹配到的模型,其次是 Provider 级别的映射)
|
||||||
mapped_model = candidate.alias_matched_model if candidate else None
|
mapped_model = candidate.mapping_matched_model if candidate else None
|
||||||
if not mapped_model:
|
if not mapped_model:
|
||||||
mapped_model = await self._get_mapped_model(
|
mapped_model = await self._get_mapped_model(
|
||||||
source_model=model,
|
source_model=model,
|
||||||
|
|||||||
@@ -437,8 +437,8 @@ class CliMessageHandlerBase(BaseMessageHandler):
|
|||||||
ctx.provider_api_format = str(endpoint.api_format) if endpoint.api_format else ""
|
ctx.provider_api_format = str(endpoint.api_format) if endpoint.api_format else ""
|
||||||
ctx.client_api_format = ctx.api_format # 已在 process_stream 中设置
|
ctx.client_api_format = ctx.api_format # 已在 process_stream 中设置
|
||||||
|
|
||||||
# 获取模型映射(优先使用别名匹配到的模型,其次是 Provider 级别的映射)
|
# 获取模型映射(优先使用映射匹配到的模型,其次是 Provider 级别的映射)
|
||||||
mapped_model = candidate.alias_matched_model if candidate else None
|
mapped_model = candidate.mapping_matched_model if candidate else None
|
||||||
if not mapped_model:
|
if not mapped_model:
|
||||||
mapped_model = await self._get_mapped_model(
|
mapped_model = await self._get_mapped_model(
|
||||||
source_model=ctx.model,
|
source_model=ctx.model,
|
||||||
@@ -1570,8 +1570,8 @@ class CliMessageHandlerBase(BaseMessageHandler):
|
|||||||
provider_name = str(provider.name)
|
provider_name = str(provider.name)
|
||||||
provider_api_format = str(endpoint.api_format) if endpoint.api_format else ""
|
provider_api_format = str(endpoint.api_format) if endpoint.api_format else ""
|
||||||
|
|
||||||
# 获取模型映射(优先使用别名匹配到的模型,其次是 Provider 级别的映射)
|
# 获取模型映射(优先使用映射匹配到的模型,其次是 Provider 级别的映射)
|
||||||
mapped_model = candidate.alias_matched_model if candidate else None
|
mapped_model = candidate.mapping_matched_model if candidate else None
|
||||||
if not mapped_model:
|
if not mapped_model:
|
||||||
mapped_model = await self._get_mapped_model(
|
mapped_model = await self._get_mapped_model(
|
||||||
source_model=model,
|
source_model=model,
|
||||||
|
|||||||
@@ -4,9 +4,9 @@
|
|||||||
allowed_models 格式: ["claude-sonnet-4", "gpt-4o"]
|
allowed_models 格式: ["claude-sonnet-4", "gpt-4o"]
|
||||||
使用 None/null 表示不限制(允许所有模型)
|
使用 None/null 表示不限制(允许所有模型)
|
||||||
|
|
||||||
支持模型别名匹配:
|
支持模型映射匹配:
|
||||||
- GlobalModel.config.model_aliases 定义别名模式
|
- GlobalModel.config.model_mappings 定义映射模式
|
||||||
- 别名模式支持正则表达式语法
|
- 映射模式支持正则表达式语法
|
||||||
- 例如:claude-haiku-.* 可匹配 claude-haiku-4.5, claude-haiku-last
|
- 例如:claude-haiku-.* 可匹配 claude-haiku-4.5, claude-haiku-last
|
||||||
- 使用 regex 库的原生超时保护(100ms)防止 ReDoS
|
- 使用 regex 库的原生超时保护(100ms)防止 ReDoS
|
||||||
"""
|
"""
|
||||||
@@ -19,10 +19,10 @@ import regex
|
|||||||
|
|
||||||
from src.core.logger import logger
|
from src.core.logger import logger
|
||||||
|
|
||||||
# 别名规则限制
|
# 映射规则限制
|
||||||
MAX_ALIASES_PER_MODEL = 50
|
MAX_MAPPINGS_PER_MODEL = 50
|
||||||
MAX_ALIAS_LENGTH = 200
|
MAX_MAPPING_LENGTH = 200
|
||||||
MAX_MODEL_NAME_LENGTH = 200 # 与 MAX_ALIAS_LENGTH 保持一致
|
MAX_MODEL_NAME_LENGTH = 200 # 与 MAX_MAPPING_LENGTH 保持一致
|
||||||
REGEX_MATCH_TIMEOUT_MS = 100 # 正则匹配超时(毫秒)
|
REGEX_MATCH_TIMEOUT_MS = 100 # 正则匹配超时(毫秒)
|
||||||
|
|
||||||
# 类型别名
|
# 类型别名
|
||||||
@@ -154,9 +154,9 @@ def parse_allowed_models_to_list(allowed_models: AllowedModels) -> List[str]:
|
|||||||
return list(allowed_models)
|
return list(allowed_models)
|
||||||
|
|
||||||
|
|
||||||
def validate_alias_pattern(pattern: str) -> Tuple[bool, Optional[str]]:
|
def validate_mapping_pattern(pattern: str) -> Tuple[bool, Optional[str]]:
|
||||||
"""
|
"""
|
||||||
验证别名模式是否安全
|
验证映射模式是否安全
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
pattern: 待验证的正则模式
|
pattern: 待验证的正则模式
|
||||||
@@ -165,10 +165,10 @@ def validate_alias_pattern(pattern: str) -> Tuple[bool, Optional[str]]:
|
|||||||
(is_valid, error_message)
|
(is_valid, error_message)
|
||||||
"""
|
"""
|
||||||
if not pattern or not pattern.strip():
|
if not pattern or not pattern.strip():
|
||||||
return False, "别名规则不能为空"
|
return False, "映射规则不能为空"
|
||||||
|
|
||||||
if len(pattern) > MAX_ALIAS_LENGTH:
|
if len(pattern) > MAX_MAPPING_LENGTH:
|
||||||
return False, f"别名规则过长 (最大 {MAX_ALIAS_LENGTH} 字符)"
|
return False, f"映射规则过长 (最大 {MAX_MAPPING_LENGTH} 字符)"
|
||||||
|
|
||||||
# 尝试编译验证语法
|
# 尝试编译验证语法
|
||||||
try:
|
try:
|
||||||
@@ -179,35 +179,35 @@ def validate_alias_pattern(pattern: str) -> Tuple[bool, Optional[str]]:
|
|||||||
return True, None
|
return True, None
|
||||||
|
|
||||||
|
|
||||||
def validate_model_aliases(aliases: Optional[List[str]]) -> Tuple[bool, Optional[str]]:
|
def validate_model_mappings(mappings: Optional[List[str]]) -> Tuple[bool, Optional[str]]:
|
||||||
"""
|
"""
|
||||||
验证别名列表是否合法
|
验证映射列表是否合法
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
aliases: 别名列表
|
mappings: 映射列表
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
(is_valid, error_message)
|
(is_valid, error_message)
|
||||||
"""
|
"""
|
||||||
if not aliases:
|
if not mappings:
|
||||||
return True, None
|
return True, None
|
||||||
|
|
||||||
if len(aliases) > MAX_ALIASES_PER_MODEL:
|
if len(mappings) > MAX_MAPPINGS_PER_MODEL:
|
||||||
return False, f"别名规则数量超限 (最大 {MAX_ALIASES_PER_MODEL} 条)"
|
return False, f"映射规则数量超限 (最大 {MAX_MAPPINGS_PER_MODEL} 条)"
|
||||||
|
|
||||||
for i, alias in enumerate(aliases):
|
for i, mapping in enumerate(mappings):
|
||||||
is_valid, error = validate_alias_pattern(alias)
|
is_valid, error = validate_mapping_pattern(mapping)
|
||||||
if not is_valid:
|
if not is_valid:
|
||||||
return False, f"第 {i + 1} 条规则无效: {error}"
|
return False, f"第 {i + 1} 条规则无效: {error}"
|
||||||
|
|
||||||
return True, None
|
return True, None
|
||||||
|
|
||||||
|
|
||||||
def validate_and_extract_model_aliases(
|
def validate_and_extract_model_mappings(
|
||||||
config: Optional[dict],
|
config: Optional[dict],
|
||||||
) -> Tuple[bool, Optional[str], Optional[List[str]]]:
|
) -> Tuple[bool, Optional[str], Optional[List[str]]]:
|
||||||
"""
|
"""
|
||||||
从 config 中验证并提取 model_aliases
|
从 config 中验证并提取 model_mappings
|
||||||
|
|
||||||
用于 GlobalModel 创建/更新时的统一验证
|
用于 GlobalModel 创建/更新时的统一验证
|
||||||
|
|
||||||
@@ -215,34 +215,34 @@ def validate_and_extract_model_aliases(
|
|||||||
config: GlobalModel 的 config 字典
|
config: GlobalModel 的 config 字典
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
(is_valid, error_message, aliases):
|
(is_valid, error_message, mappings):
|
||||||
- is_valid: 验证是否通过
|
- is_valid: 验证是否通过
|
||||||
- error_message: 错误信息(验证失败时)
|
- error_message: 错误信息(验证失败时)
|
||||||
- aliases: 提取的别名列表(验证成功时)
|
- mappings: 提取的映射列表(验证成功时)
|
||||||
"""
|
"""
|
||||||
if not config or "model_aliases" not in config:
|
if not config or "model_mappings" not in config:
|
||||||
return True, None, None
|
return True, None, None
|
||||||
|
|
||||||
aliases = config.get("model_aliases")
|
mappings = config.get("model_mappings")
|
||||||
|
|
||||||
# 允许显式设置为 None(表示清除别名)
|
# 允许显式设置为 None(表示清除映射)
|
||||||
if aliases is None:
|
if mappings is None:
|
||||||
return True, None, None
|
return True, None, None
|
||||||
|
|
||||||
# 类型验证:必须是列表
|
# 类型验证:必须是列表
|
||||||
if not isinstance(aliases, list):
|
if not isinstance(mappings, list):
|
||||||
return False, "model_aliases 必须是数组类型", None
|
return False, "model_mappings 必须是数组类型", None
|
||||||
|
|
||||||
# 元素类型验证:必须是字符串
|
# 元素类型验证:必须是字符串
|
||||||
if not all(isinstance(a, str) for a in aliases):
|
if not all(isinstance(m, str) for m in mappings):
|
||||||
return False, "model_aliases 数组元素必须是字符串", None
|
return False, "model_mappings 数组元素必须是字符串", None
|
||||||
|
|
||||||
# 业务规则验证
|
# 业务规则验证
|
||||||
is_valid, error = validate_model_aliases(aliases)
|
is_valid, error = validate_model_mappings(mappings)
|
||||||
if not is_valid:
|
if not is_valid:
|
||||||
return False, error, None
|
return False, error, None
|
||||||
|
|
||||||
return True, None, aliases
|
return True, None, mappings
|
||||||
|
|
||||||
|
|
||||||
@lru_cache(maxsize=2000)
|
@lru_cache(maxsize=2000)
|
||||||
@@ -267,7 +267,7 @@ def clear_regex_cache() -> None:
|
|||||||
"""
|
"""
|
||||||
清空正则缓存
|
清空正则缓存
|
||||||
|
|
||||||
在 GlobalModel 别名更新时调用此函数以确保缓存一致性
|
在 GlobalModel 映射更新时调用此函数以确保缓存一致性
|
||||||
"""
|
"""
|
||||||
_compile_pattern_cached.cache_clear()
|
_compile_pattern_cached.cache_clear()
|
||||||
logger.debug("[RegexCache] 缓存已清空")
|
logger.debug("[RegexCache] 缓存已清空")
|
||||||
@@ -310,7 +310,7 @@ def _match_with_timeout(
|
|||||||
|
|
||||||
def match_model_with_pattern(pattern: str, model_name: str) -> bool:
|
def match_model_with_pattern(pattern: str, model_name: str) -> bool:
|
||||||
"""
|
"""
|
||||||
检查模型名是否匹配别名模式(支持正则表达式)
|
检查模型名是否匹配映射模式(支持正则表达式)
|
||||||
|
|
||||||
安全特性:
|
安全特性:
|
||||||
- 长度限制检查
|
- 长度限制检查
|
||||||
@@ -318,7 +318,7 @@ def match_model_with_pattern(pattern: str, model_name: str) -> bool:
|
|||||||
- 正则匹配超时保护(100ms,使用 regex 库原生超时)
|
- 正则匹配超时保护(100ms,使用 regex 库原生超时)
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
pattern: 别名模式,支持正则表达式语法
|
pattern: 映射模式,支持正则表达式语法
|
||||||
model_name: 被检查的模型名(来自 Key 的 allowed_models)
|
model_name: 被检查的模型名(来自 Key 的 allowed_models)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
@@ -334,7 +334,7 @@ def match_model_with_pattern(pattern: str, model_name: str) -> bool:
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
# 长度检查
|
# 长度检查
|
||||||
if len(pattern) > MAX_ALIAS_LENGTH or len(model_name) > MAX_MODEL_NAME_LENGTH:
|
if len(pattern) > MAX_MAPPING_LENGTH or len(model_name) > MAX_MODEL_NAME_LENGTH:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# 使用缓存的编译结果
|
# 使用缓存的编译结果
|
||||||
@@ -347,45 +347,45 @@ def match_model_with_pattern(pattern: str, model_name: str) -> bool:
|
|||||||
return result is True
|
return result is True
|
||||||
|
|
||||||
|
|
||||||
def check_model_allowed_with_aliases(
|
def check_model_allowed_with_mappings(
|
||||||
model_name: str,
|
model_name: str,
|
||||||
allowed_models: AllowedModels,
|
allowed_models: AllowedModels,
|
||||||
resolved_model_name: Optional[str] = None,
|
resolved_model_name: Optional[str] = None,
|
||||||
model_aliases: Optional[List[str]] = None,
|
model_mappings: Optional[List[str]] = None,
|
||||||
candidate_models: Optional[set[str]] = None,
|
candidate_models: Optional[set[str]] = None,
|
||||||
) -> tuple[bool, Optional[str]]:
|
) -> tuple[bool, Optional[str]]:
|
||||||
"""
|
"""
|
||||||
检查模型是否被允许(支持别名通配符匹配)
|
检查模型是否被允许(支持映射通配符匹配)
|
||||||
|
|
||||||
匹配优先级:
|
匹配优先级:
|
||||||
1. 精确匹配 model_name(用户请求的模型名)
|
1. 精确匹配 model_name(用户请求的模型名)
|
||||||
2. 精确匹配 resolved_model_name(GlobalModel.name)
|
2. 精确匹配 resolved_model_name(GlobalModel.name)
|
||||||
3. 遍历 model_aliases,检查每个别名是否匹配 allowed_models 中的任一项
|
3. 遍历 model_mappings,检查每个映射是否匹配 allowed_models 中的任一项
|
||||||
|
|
||||||
别名匹配顺序说明:
|
映射匹配顺序说明:
|
||||||
- 按 allowed_models 集合的迭代顺序遍历(通常为字母顺序,因为内部使用 set)
|
- 按 allowed_models 集合的迭代顺序遍历(通常为字母顺序,因为内部使用 set)
|
||||||
- 对于每个 allowed_model,按 model_aliases 数组顺序依次尝试匹配
|
- 对于每个 allowed_model,按 model_mappings 数组顺序依次尝试匹配
|
||||||
- 返回第一个成功匹配的 allowed_model
|
- 返回第一个成功匹配的 allowed_model
|
||||||
- 如需确定性行为,请确保 model_aliases 中的规则从最具体到最通用排序
|
- 如需确定性行为,请确保 model_mappings 中的规则从最具体到最通用排序
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
model_name: 请求的模型名称
|
model_name: 请求的模型名称
|
||||||
allowed_models: 允许的模型配置(来自 Provider Key)
|
allowed_models: 允许的模型配置(来自 Provider Key)
|
||||||
resolved_model_name: 解析后的 GlobalModel.name
|
resolved_model_name: 解析后的 GlobalModel.name
|
||||||
model_aliases: GlobalModel 的别名列表(来自 config.model_aliases)
|
model_mappings: GlobalModel 的映射列表(来自 config.model_mappings)
|
||||||
candidate_models: 可选的候选模型集合(用于限制别名匹配只能落到这些模型名上)
|
candidate_models: 可选的候选模型集合(用于限制映射匹配只能落到这些模型名上)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
(is_allowed, matched_model_name):
|
(is_allowed, matched_model_name):
|
||||||
- is_allowed: 是否允许使用该模型
|
- is_allowed: 是否允许使用该模型
|
||||||
- matched_model_name: 通过别名匹配到的模型名(仅别名匹配时有值,精确匹配时为 None)
|
- matched_model_name: 通过映射匹配到的模型名(仅映射匹配时有值,精确匹配时为 None)
|
||||||
"""
|
"""
|
||||||
# 先尝试精确匹配(使用原有逻辑)
|
# 先尝试精确匹配(使用原有逻辑)
|
||||||
if check_model_allowed(model_name, allowed_models, resolved_model_name):
|
if check_model_allowed(model_name, allowed_models, resolved_model_name):
|
||||||
return True, None
|
return True, None
|
||||||
|
|
||||||
# 如果精确匹配失败且有别名配置,尝试别名匹配
|
# 如果精确匹配失败且有映射配置,尝试映射匹配
|
||||||
if not model_aliases:
|
if not model_mappings:
|
||||||
return False, None
|
return False, None
|
||||||
|
|
||||||
# 获取 allowed_models 的集合
|
# 获取 allowed_models 的集合
|
||||||
@@ -398,18 +398,18 @@ def check_model_allowed_with_aliases(
|
|||||||
# 空集合 = 拒绝所有
|
# 空集合 = 拒绝所有
|
||||||
return False, None
|
return False, None
|
||||||
|
|
||||||
# 如果提供了候选集合,只允许在候选集合中进行别名匹配
|
# 如果提供了候选集合,只允许在候选集合中进行映射匹配
|
||||||
if candidate_models is not None:
|
if candidate_models is not None:
|
||||||
allowed_set = allowed_set & candidate_models
|
allowed_set = allowed_set & candidate_models
|
||||||
if len(allowed_set) == 0:
|
if len(allowed_set) == 0:
|
||||||
return False, None
|
return False, None
|
||||||
|
|
||||||
# 遍历 allowed_models 中的每个模型名,检查是否有别名能匹配
|
# 遍历 allowed_models 中的每个模型名,检查是否有映射能匹配
|
||||||
# 注意:为了避免 set 迭代顺序带来的非确定性,这里对 allowed_set 做排序
|
# 注意:为了避免 set 迭代顺序带来的非确定性,这里对 allowed_set 做排序
|
||||||
# 返回第一个匹配的模型名,匹配顺序由 allowed_models 排序结果和 model_aliases 数组顺序共同决定
|
# 返回第一个匹配的模型名,匹配顺序由 allowed_models 排序结果和 model_mappings 数组顺序共同决定
|
||||||
for allowed_model in sorted(allowed_set):
|
for allowed_model in sorted(allowed_set):
|
||||||
for alias_pattern in model_aliases:
|
for mapping_pattern in model_mappings:
|
||||||
if match_model_with_pattern(alias_pattern, allowed_model):
|
if match_model_with_pattern(mapping_pattern, allowed_model):
|
||||||
# 返回匹配到的模型名,用于实际请求
|
# 返回匹配到的模型名,用于实际请求
|
||||||
return True, allowed_model
|
return True, allowed_model
|
||||||
|
|
||||||
|
|||||||
54
src/services/cache/aware_scheduler.py
vendored
54
src/services/cache/aware_scheduler.py
vendored
@@ -77,7 +77,7 @@ class ProviderCandidate:
|
|||||||
is_cached: bool = False
|
is_cached: bool = False
|
||||||
is_skipped: bool = False # 是否被跳过
|
is_skipped: bool = False # 是否被跳过
|
||||||
skip_reason: Optional[str] = None # 跳过原因
|
skip_reason: Optional[str] = None # 跳过原因
|
||||||
alias_matched_model: Optional[str] = None # 通过别名匹配到的模型名(用于实际请求)
|
mapping_matched_model: Optional[str] = None # 通过映射匹配到的模型名(用于实际请求)
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -580,7 +580,7 @@ class CacheAwareScheduler:
|
|||||||
target_format = normalize_api_format(api_format)
|
target_format = normalize_api_format(api_format)
|
||||||
|
|
||||||
# 0. 解析 model_name 到 GlobalModel(支持直接匹配和映射名匹配,使用 ModelCacheService)
|
# 0. 解析 model_name 到 GlobalModel(支持直接匹配和映射名匹配,使用 ModelCacheService)
|
||||||
global_model = await ModelCacheService.resolve_global_model_by_name_or_alias(db, model_name)
|
global_model = await ModelCacheService.resolve_global_model_by_name_or_mapping(db, model_name)
|
||||||
|
|
||||||
if not global_model:
|
if not global_model:
|
||||||
logger.warning(f"GlobalModel not found: {model_name}")
|
logger.warning(f"GlobalModel not found: {model_name}")
|
||||||
@@ -591,8 +591,8 @@ class CacheAwareScheduler:
|
|||||||
requested_model_name = model_name
|
requested_model_name = model_name
|
||||||
resolved_model_name = str(global_model.name)
|
resolved_model_name = str(global_model.name)
|
||||||
|
|
||||||
# 提取模型别名(用于 Provider Key 的 allowed_models 匹配)
|
# 提取模型映射(用于 Provider Key 的 allowed_models 匹配)
|
||||||
model_aliases: List[str] = (global_model.config or {}).get("model_aliases", [])
|
model_mappings: List[str] = (global_model.config or {}).get("model_mappings", [])
|
||||||
|
|
||||||
# 获取合并后的访问限制(ApiKey + User)
|
# 获取合并后的访问限制(ApiKey + User)
|
||||||
restrictions = self._get_effective_restrictions(user_api_key)
|
restrictions = self._get_effective_restrictions(user_api_key)
|
||||||
@@ -660,7 +660,7 @@ class CacheAwareScheduler:
|
|||||||
target_format=target_format,
|
target_format=target_format,
|
||||||
model_name=requested_model_name,
|
model_name=requested_model_name,
|
||||||
resolved_model_name=resolved_model_name,
|
resolved_model_name=resolved_model_name,
|
||||||
model_aliases=model_aliases,
|
model_mappings=model_mappings,
|
||||||
affinity_key=affinity_key,
|
affinity_key=affinity_key,
|
||||||
max_candidates=max_candidates,
|
max_candidates=max_candidates,
|
||||||
is_stream=is_stream,
|
is_stream=is_stream,
|
||||||
@@ -774,7 +774,7 @@ class CacheAwareScheduler:
|
|||||||
- provider_model_names: Provider 侧可用的模型名称集合(主名称 + 映射名称,按 api_format 过滤)
|
- provider_model_names: Provider 侧可用的模型名称集合(主名称 + 映射名称,按 api_format 过滤)
|
||||||
"""
|
"""
|
||||||
# 使用 ModelCacheService 解析模型名称(支持映射名)
|
# 使用 ModelCacheService 解析模型名称(支持映射名)
|
||||||
global_model = await ModelCacheService.resolve_global_model_by_name_or_alias(db, model_name)
|
global_model = await ModelCacheService.resolve_global_model_by_name_or_mapping(db, model_name)
|
||||||
|
|
||||||
if not global_model:
|
if not global_model:
|
||||||
# 完全未找到匹配
|
# 完全未找到匹配
|
||||||
@@ -883,7 +883,7 @@ class CacheAwareScheduler:
|
|||||||
model_name: str,
|
model_name: str,
|
||||||
capability_requirements: Optional[Dict[str, bool]] = None,
|
capability_requirements: Optional[Dict[str, bool]] = None,
|
||||||
resolved_model_name: Optional[str] = None,
|
resolved_model_name: Optional[str] = None,
|
||||||
model_aliases: Optional[List[str]] = None,
|
model_mappings: Optional[List[str]] = None,
|
||||||
candidate_models: Optional[set[str]] = None,
|
candidate_models: Optional[set[str]] = None,
|
||||||
) -> Tuple[bool, Optional[str], Optional[str]]:
|
) -> Tuple[bool, Optional[str], Optional[str]]:
|
||||||
"""
|
"""
|
||||||
@@ -897,14 +897,14 @@ class CacheAwareScheduler:
|
|||||||
model_name: 模型名称
|
model_name: 模型名称
|
||||||
capability_requirements: 能力需求(可选)
|
capability_requirements: 能力需求(可选)
|
||||||
resolved_model_name: 解析后的 GlobalModel.name(可选)
|
resolved_model_name: 解析后的 GlobalModel.name(可选)
|
||||||
model_aliases: GlobalModel 的别名列表(用于通配符匹配)
|
model_mappings: GlobalModel 的映射列表(用于通配符匹配)
|
||||||
candidate_models: Provider 侧可用的模型名称集合(用于限制别名匹配范围)
|
candidate_models: Provider 侧可用的模型名称集合(用于限制映射匹配范围)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
(is_available, skip_reason, alias_matched_model)
|
(is_available, skip_reason, mapping_matched_model)
|
||||||
- is_available: Key 是否可用
|
- is_available: Key 是否可用
|
||||||
- skip_reason: 不可用时的原因
|
- skip_reason: 不可用时的原因
|
||||||
- alias_matched_model: 通过别名匹配到的模型名(用于实际请求)
|
- mapping_matched_model: 通过映射匹配到的模型名(用于实际请求)
|
||||||
"""
|
"""
|
||||||
# 检查熔断器状态(使用详细状态方法获取更丰富的跳过原因,按 API 格式)
|
# 检查熔断器状态(使用详细状态方法获取更丰富的跳过原因,按 API 格式)
|
||||||
is_available, circuit_reason = health_monitor.get_circuit_breaker_status(
|
is_available, circuit_reason = health_monitor.get_circuit_breaker_status(
|
||||||
@@ -915,33 +915,33 @@ class CacheAwareScheduler:
|
|||||||
|
|
||||||
# 模型权限检查:使用 allowed_models 白名单
|
# 模型权限检查:使用 allowed_models 白名单
|
||||||
# None = 允许所有模型,[] = 拒绝所有模型,["a","b"] = 只允许指定模型
|
# None = 允许所有模型,[] = 拒绝所有模型,["a","b"] = 只允许指定模型
|
||||||
# 支持通配符别名匹配(通过 model_aliases)
|
# 支持通配符映射匹配(通过 model_mappings)
|
||||||
from src.core.model_permissions import (
|
from src.core.model_permissions import (
|
||||||
check_model_allowed_with_aliases,
|
check_model_allowed_with_mappings,
|
||||||
get_allowed_models_preview,
|
get_allowed_models_preview,
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
is_allowed, alias_matched_model = check_model_allowed_with_aliases(
|
is_allowed, mapping_matched_model = check_model_allowed_with_mappings(
|
||||||
model_name=model_name,
|
model_name=model_name,
|
||||||
allowed_models=key.allowed_models,
|
allowed_models=key.allowed_models,
|
||||||
resolved_model_name=resolved_model_name,
|
resolved_model_name=resolved_model_name,
|
||||||
model_aliases=model_aliases,
|
model_mappings=model_mappings,
|
||||||
candidate_models=candidate_models,
|
candidate_models=candidate_models,
|
||||||
)
|
)
|
||||||
except TimeoutError:
|
except TimeoutError:
|
||||||
# 正则匹配超时(可能是 ReDoS 攻击或复杂模式)
|
# 正则匹配超时(可能是 ReDoS 攻击或复杂模式)
|
||||||
logger.warning(f"别名匹配超时: key_id={key.id}, model={model_name}")
|
logger.warning(f"映射匹配超时: key_id={key.id}, model={model_name}")
|
||||||
return False, "别名匹配超时,请简化配置", None
|
return False, "映射匹配超时,请简化配置", None
|
||||||
except re.error as e:
|
except re.error as e:
|
||||||
# 正则语法错误(配置问题)
|
# 正则语法错误(配置问题)
|
||||||
logger.warning(f"别名规则无效: key_id={key.id}, model={model_name}, error={e}")
|
logger.warning(f"映射规则无效: key_id={key.id}, model={model_name}, error={e}")
|
||||||
return False, f"别名规则无效: {str(e)}", None
|
return False, f"映射规则无效: {str(e)}", None
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# 其他未知异常
|
# 其他未知异常
|
||||||
logger.error(f"别名匹配异常: key_id={key.id}, model={model_name}, error={e}", exc_info=True)
|
logger.error(f"映射匹配异常: key_id={key.id}, model={model_name}, error={e}", exc_info=True)
|
||||||
# 异常时保守处理:不允许使用该 Key
|
# 异常时保守处理:不允许使用该 Key
|
||||||
return False, "别名匹配失败", None
|
return False, "映射匹配失败", None
|
||||||
|
|
||||||
if not is_allowed:
|
if not is_allowed:
|
||||||
return False, f"模型权限不匹配(允许: {get_allowed_models_preview(key.allowed_models)})", None
|
return False, f"模型权限不匹配(允许: {get_allowed_models_preview(key.allowed_models)})", None
|
||||||
@@ -957,7 +957,7 @@ class CacheAwareScheduler:
|
|||||||
if not is_match:
|
if not is_match:
|
||||||
return False, skip_reason, None
|
return False, skip_reason, None
|
||||||
|
|
||||||
return True, None, alias_matched_model
|
return True, None, mapping_matched_model
|
||||||
|
|
||||||
async def _build_candidates(
|
async def _build_candidates(
|
||||||
self,
|
self,
|
||||||
@@ -967,7 +967,7 @@ class CacheAwareScheduler:
|
|||||||
model_name: str,
|
model_name: str,
|
||||||
affinity_key: Optional[str],
|
affinity_key: Optional[str],
|
||||||
resolved_model_name: Optional[str] = None,
|
resolved_model_name: Optional[str] = None,
|
||||||
model_aliases: Optional[List[str]] = None,
|
model_mappings: Optional[List[str]] = None,
|
||||||
max_candidates: Optional[int] = None,
|
max_candidates: Optional[int] = None,
|
||||||
is_stream: bool = False,
|
is_stream: bool = False,
|
||||||
capability_requirements: Optional[Dict[str, bool]] = None,
|
capability_requirements: Optional[Dict[str, bool]] = None,
|
||||||
@@ -984,7 +984,7 @@ class CacheAwareScheduler:
|
|||||||
model_name: 模型名称(用户请求的名称,可能是映射名)
|
model_name: 模型名称(用户请求的名称,可能是映射名)
|
||||||
affinity_key: 亲和性标识符(通常为API Key ID)
|
affinity_key: 亲和性标识符(通常为API Key ID)
|
||||||
resolved_model_name: 解析后的 GlobalModel.name(用于 Key.allowed_models 校验)
|
resolved_model_name: 解析后的 GlobalModel.name(用于 Key.allowed_models 校验)
|
||||||
model_aliases: GlobalModel 的别名列表(用于 Key.allowed_models 通配符匹配)
|
model_mappings: GlobalModel 的映射列表(用于 Key.allowed_models 通配符匹配)
|
||||||
max_candidates: 最大候选数
|
max_candidates: 最大候选数
|
||||||
is_stream: 是否是流式请求,如果为 True 则过滤不支持流式的 Provider
|
is_stream: 是否是流式请求,如果为 True 则过滤不支持流式的 Provider
|
||||||
capability_requirements: 能力需求(可选)
|
capability_requirements: 能力需求(可选)
|
||||||
@@ -1047,13 +1047,13 @@ class CacheAwareScheduler:
|
|||||||
|
|
||||||
for key in keys:
|
for key in keys:
|
||||||
# Key 级别的能力检查
|
# Key 级别的能力检查
|
||||||
is_available, skip_reason, alias_matched_model = self._check_key_availability(
|
is_available, skip_reason, mapping_matched_model = self._check_key_availability(
|
||||||
key,
|
key,
|
||||||
target_format_str,
|
target_format_str,
|
||||||
model_name,
|
model_name,
|
||||||
capability_requirements,
|
capability_requirements,
|
||||||
resolved_model_name=resolved_model_name,
|
resolved_model_name=resolved_model_name,
|
||||||
model_aliases=model_aliases,
|
model_mappings=model_mappings,
|
||||||
candidate_models=provider_model_names,
|
candidate_models=provider_model_names,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1063,7 +1063,7 @@ class CacheAwareScheduler:
|
|||||||
key=key,
|
key=key,
|
||||||
is_skipped=not is_available,
|
is_skipped=not is_available,
|
||||||
skip_reason=skip_reason,
|
skip_reason=skip_reason,
|
||||||
alias_matched_model=alias_matched_model,
|
mapping_matched_model=mapping_matched_model,
|
||||||
)
|
)
|
||||||
candidates.append(candidate)
|
candidates.append(candidate)
|
||||||
|
|
||||||
|
|||||||
4
src/services/cache/model_cache.py
vendored
4
src/services/cache/model_cache.py
vendored
@@ -15,7 +15,7 @@ Model 映射缓存服务 - 减少模型查询
|
|||||||
|
|
||||||
使用示例
|
使用示例
|
||||||
--------
|
--------
|
||||||
global_model = await ModelCacheService.resolve_global_model_by_name_or_alias(db, "gpt-4")
|
global_model = await ModelCacheService.resolve_global_model_by_name_or_mapping(db, "gpt-4")
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import time
|
import time
|
||||||
@@ -250,7 +250,7 @@ class ModelCacheService:
|
|||||||
logger.debug(f"GlobalModel 缓存已清除: {global_model_id}")
|
logger.debug(f"GlobalModel 缓存已清除: {global_model_id}")
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def resolve_global_model_by_name_or_alias(
|
async def resolve_global_model_by_name_or_mapping(
|
||||||
db: Session, model_name: str
|
db: Session, model_name: str
|
||||||
) -> Optional[GlobalModel]:
|
) -> Optional[GlobalModel]:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ class ModelMapperMiddleware:
|
|||||||
mapping = None
|
mapping = None
|
||||||
|
|
||||||
# 步骤 1: 解析 GlobalModel(支持映射名)
|
# 步骤 1: 解析 GlobalModel(支持映射名)
|
||||||
global_model = await ModelCacheService.resolve_global_model_by_name_or_alias(
|
global_model = await ModelCacheService.resolve_global_model_by_name_or_mapping(
|
||||||
self.db, source_model
|
self.db, source_model
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -77,6 +77,24 @@ class ModelService:
|
|||||||
|
|
||||||
logger.info(f"创建模型成功: provider={provider.name}, model={model.provider_model_name}, global_model_id={model.global_model_id}")
|
logger.info(f"创建模型成功: provider={provider.name}, model={model.provider_model_name}, global_model_id={model.global_model_id}")
|
||||||
|
|
||||||
|
# 清除 Redis 缓存(异步执行,不阻塞返回)
|
||||||
|
# 重要:新增模型可能需要清除 resolver 的 NOT_FOUND 负缓存(global_model:resolve:*),
|
||||||
|
# 否则请求链路在 TTL 内可能无法立刻解析到新模型。
|
||||||
|
asyncio.create_task(
|
||||||
|
ModelCacheService.invalidate_model_cache(
|
||||||
|
model_id=model.id,
|
||||||
|
provider_id=model.provider_id,
|
||||||
|
global_model_id=model.global_model_id,
|
||||||
|
provider_model_name=model.provider_model_name,
|
||||||
|
provider_model_mappings=model.provider_model_mappings,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# 清除内存缓存(ModelMapperMiddleware 实例)
|
||||||
|
if model.provider_id and model.global_model_id:
|
||||||
|
cache_service = get_cache_invalidation_service()
|
||||||
|
cache_service.on_model_changed(model.provider_id, model.global_model_id)
|
||||||
|
|
||||||
# 清除 /v1/models 列表缓存
|
# 清除 /v1/models 列表缓存
|
||||||
asyncio.create_task(invalidate_models_list_cache())
|
asyncio.create_task(invalidate_models_list_cache())
|
||||||
|
|
||||||
@@ -154,6 +172,7 @@ class ModelService:
|
|||||||
raise NotFoundException(f"模型 {model_id} 不存在")
|
raise NotFoundException(f"模型 {model_id} 不存在")
|
||||||
|
|
||||||
# 保存旧的映射,用于清除缓存
|
# 保存旧的映射,用于清除缓存
|
||||||
|
old_global_model_id = model.global_model_id
|
||||||
old_provider_model_name = model.provider_model_name
|
old_provider_model_name = model.provider_model_name
|
||||||
old_provider_model_mappings = model.provider_model_mappings
|
old_provider_model_mappings = model.provider_model_mappings
|
||||||
|
|
||||||
@@ -179,14 +198,17 @@ class ModelService:
|
|||||||
ModelCacheService.invalidate_model_cache(
|
ModelCacheService.invalidate_model_cache(
|
||||||
model_id=model.id,
|
model_id=model.id,
|
||||||
provider_id=model.provider_id,
|
provider_id=model.provider_id,
|
||||||
global_model_id=model.global_model_id,
|
global_model_id=old_global_model_id,
|
||||||
provider_model_name=old_provider_model_name,
|
provider_model_name=old_provider_model_name,
|
||||||
provider_model_mappings=old_provider_model_mappings,
|
provider_model_mappings=old_provider_model_mappings,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
# 再清除新的映射缓存(如果有变化)
|
# 再清除新的映射缓存(如果有变化,包括 global_model_id 变更)
|
||||||
if (model.provider_model_name != old_provider_model_name or
|
if (
|
||||||
model.provider_model_mappings != old_provider_model_mappings):
|
model.provider_model_name != old_provider_model_name
|
||||||
|
or model.provider_model_mappings != old_provider_model_mappings
|
||||||
|
or model.global_model_id != old_global_model_id
|
||||||
|
):
|
||||||
asyncio.create_task(
|
asyncio.create_task(
|
||||||
ModelCacheService.invalidate_model_cache(
|
ModelCacheService.invalidate_model_cache(
|
||||||
model_id=model.id,
|
model_id=model.id,
|
||||||
@@ -354,6 +376,7 @@ class ModelService:
|
|||||||
provider_id=provider_id,
|
provider_id=provider_id,
|
||||||
global_model_id=model_data.global_model_id,
|
global_model_id=model_data.global_model_id,
|
||||||
provider_model_name=model_data.provider_model_name,
|
provider_model_name=model_data.provider_model_name,
|
||||||
|
provider_model_mappings=model_data.provider_model_mappings,
|
||||||
price_per_request=model_data.price_per_request,
|
price_per_request=model_data.price_per_request,
|
||||||
tiered_pricing=model_data.tiered_pricing,
|
tiered_pricing=model_data.tiered_pricing,
|
||||||
supports_vision=model_data.supports_vision,
|
supports_vision=model_data.supports_vision,
|
||||||
@@ -373,6 +396,23 @@ class ModelService:
|
|||||||
db.refresh(model)
|
db.refresh(model)
|
||||||
logger.info(f"批量创建 {len(created_models)} 个模型成功")
|
logger.info(f"批量创建 {len(created_models)} 个模型成功")
|
||||||
|
|
||||||
|
# 清除 Redis 缓存(异步执行,不阻塞返回)
|
||||||
|
# 逐个清除 resolver 的映射缓存,避免 NOT_FOUND 负缓存阻塞新模型生效。
|
||||||
|
for model in created_models:
|
||||||
|
asyncio.create_task(
|
||||||
|
ModelCacheService.invalidate_model_cache(
|
||||||
|
model_id=model.id,
|
||||||
|
provider_id=model.provider_id,
|
||||||
|
global_model_id=model.global_model_id,
|
||||||
|
provider_model_name=model.provider_model_name,
|
||||||
|
provider_model_mappings=model.provider_model_mappings,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# 清除内存缓存(ModelMapperMiddleware 实例)
|
||||||
|
cache_service = get_cache_invalidation_service()
|
||||||
|
cache_service.on_model_changed(provider_id, created_models[0].global_model_id)
|
||||||
|
|
||||||
# 清除 /v1/models 列表缓存
|
# 清除 /v1/models 列表缓存
|
||||||
asyncio.create_task(invalidate_models_list_cache())
|
asyncio.create_task(invalidate_models_list_cache())
|
||||||
except IntegrityError as e:
|
except IntegrityError as e:
|
||||||
|
|||||||
@@ -1,44 +1,44 @@
|
|||||||
from src.core.model_permissions import check_model_allowed_with_aliases
|
from src.core.model_permissions import check_model_allowed_with_mappings
|
||||||
|
|
||||||
|
|
||||||
class TestCheckModelAllowedWithAliases:
|
class TestCheckModelAllowedWithMappings:
|
||||||
def test_exact_match_returns_allowed_without_mapping(self) -> None:
|
def test_exact_match_returns_allowed_without_mapping(self) -> None:
|
||||||
is_allowed, matched = check_model_allowed_with_aliases(
|
is_allowed, matched = check_model_allowed_with_mappings(
|
||||||
model_name="gpt-4o",
|
model_name="gpt-4o",
|
||||||
allowed_models=["gpt-4o"],
|
allowed_models=["gpt-4o"],
|
||||||
resolved_model_name="gpt-4o",
|
resolved_model_name="gpt-4o",
|
||||||
model_aliases=[r"gpt-4o-.*"],
|
model_mappings=[r"gpt-4o-.*"],
|
||||||
)
|
)
|
||||||
assert is_allowed is True
|
assert is_allowed is True
|
||||||
assert matched is None
|
assert matched is None
|
||||||
|
|
||||||
def test_alias_match_is_deterministic(self) -> None:
|
def test_mapping_match_is_deterministic(self) -> None:
|
||||||
is_allowed, matched = check_model_allowed_with_aliases(
|
is_allowed, matched = check_model_allowed_with_mappings(
|
||||||
model_name="target",
|
model_name="target",
|
||||||
allowed_models=["b", "a"],
|
allowed_models=["b", "a"],
|
||||||
resolved_model_name="target",
|
resolved_model_name="target",
|
||||||
model_aliases=[r".*"],
|
model_mappings=[r".*"],
|
||||||
)
|
)
|
||||||
assert is_allowed is True
|
assert is_allowed is True
|
||||||
assert matched == "a"
|
assert matched == "a"
|
||||||
|
|
||||||
def test_alias_match_respects_candidate_models(self) -> None:
|
def test_mapping_match_respects_candidate_models(self) -> None:
|
||||||
is_allowed, matched = check_model_allowed_with_aliases(
|
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"],
|
||||||
resolved_model_name="target",
|
resolved_model_name="target",
|
||||||
model_aliases=[r".*-1"],
|
model_mappings=[r".*-1"],
|
||||||
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_alias_match_candidate_models_no_intersection(self) -> None:
|
def test_mapping_match_candidate_models_no_intersection(self) -> None:
|
||||||
is_allowed, matched = check_model_allowed_with_aliases(
|
is_allowed, matched = check_model_allowed_with_mappings(
|
||||||
model_name="target",
|
model_name="target",
|
||||||
allowed_models=["allowed-1"],
|
allowed_models=["allowed-1"],
|
||||||
resolved_model_name="target",
|
resolved_model_name="target",
|
||||||
model_aliases=[r".*-1"],
|
model_mappings=[r".*-1"],
|
||||||
candidate_models={"not-present"},
|
candidate_models={"not-present"},
|
||||||
)
|
)
|
||||||
assert is_allowed is False
|
assert is_allowed is False
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
测试 aware_scheduler 的模型支持检查逻辑
|
测试 aware_scheduler 的模型支持检查逻辑
|
||||||
|
|
||||||
场景:
|
场景:
|
||||||
- GlobalModel claude-haiku 配置了 model_aliases: ["haiku", "claude.*haiku.*"]
|
- GlobalModel claude-haiku 配置了 model_mappings: ["haiku", "claude.*haiku.*"]
|
||||||
- Provider A:Model 表有记录关联到 claude-haiku
|
- Provider A:Model 表有记录关联到 claude-haiku
|
||||||
- Provider B:Model 表没有记录关联到 claude-haiku
|
- Provider B:Model 表没有记录关联到 claude-haiku
|
||||||
- 用户请求 haiku 模型
|
- 用户请求 haiku 模型
|
||||||
|
|||||||
@@ -64,6 +64,6 @@ async def test_resolve_global_model_prefers_direct_match(monkeypatch) -> None:
|
|||||||
)
|
)
|
||||||
db = _FakeSession(direct_match=global_model)
|
db = _FakeSession(direct_match=global_model)
|
||||||
|
|
||||||
resolved = await ModelCacheService.resolve_global_model_by_name_or_alias(db, global_model.name)
|
resolved = await ModelCacheService.resolve_global_model_by_name_or_mapping(db, global_model.name)
|
||||||
assert resolved is global_model
|
assert resolved is global_model
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user