feat(models): 模型映射支持按提供商分组并自动关联

- 映射匹配结果按提供商分组显示,支持单个关联
- 保存映射规则时自动批量关联未关联的提供商
- 优化模型详情加载逻辑,并行获取最新数据
- 移除冗余的调度器日志输出
This commit is contained in:
fawney19
2026-01-15 02:11:44 +08:00
parent 84f46d5ea7
commit 4835ca13f5
5 changed files with 169 additions and 28 deletions

View File

@@ -437,10 +437,13 @@
<div v-show="detailTab === 'mappings'">
<ModelMappingsTab
v-if="model"
ref="modelMappingsTabRef"
:global-model-id="model.id"
:model-name="model.name"
:mappings="model.config?.model_mappings || []"
@update="handleMappingsUpdate"
@link-provider="(providerId) => $emit('linkProvider', providerId)"
@link-providers="(providerIds) => $emit('linkProviders', providerIds)"
/>
</div>
</div>
@@ -500,6 +503,8 @@ const emit = defineEmits<{
'deleteProvider': [provider: any]
'toggleProviderStatus': [provider: any]
'refreshModel': []
'linkProvider': [providerId: string]
'linkProviders': [providerIds: string[]]
}>()
const { success: showSuccess, error: showError } = useToast()
const { copyToClipboard } = useClipboard()
@@ -513,6 +518,8 @@ interface Props {
// RoutingTab 引用
const routingTabRef = ref<InstanceType<typeof RoutingTab> | null>(null)
// ModelMappingsTab 引用
const modelMappingsTabRef = ref<InstanceType<typeof ModelMappingsTab> | null>(null)
// 将 RoutingProviderInfo 转换为父组件期望的格式
function convertRoutingProviderToLegacyFormat(provider: RoutingProviderInfo) {
@@ -542,6 +549,7 @@ function handleDeleteProviderFromRouting(provider: RoutingProviderInfo) {
// 刷新路由数据
function refreshRoutingData() {
routingTabRef.value?.loadRoutingData?.()
modelMappingsTabRef.value?.refresh?.()
}
// 处理模型映射更新

View File

@@ -108,7 +108,7 @@
</div>
</div>
<!-- 展开内容匹配的 Key 列表 -->
<!-- 展开内容匹配的 Key 列表按提供商分组 -->
<div
v-if="expandedIndex === index"
class="border-t bg-muted/10 px-4 py-3"
@@ -117,34 +117,65 @@
<RefreshCw class="w-4 h-4 animate-spin text-muted-foreground" />
</div>
<div v-else-if="getMatchedKeysForMapping(mapping).length === 0" class="text-center py-4">
<div v-else-if="getMatchedKeysGroupedByProvider(mapping).length === 0" class="text-center py-4">
<p class="text-sm text-muted-foreground">
{{ mapping.trim() ? '此规则暂无匹配的 Key 白名单' : '请输入映射规则' }}
</p>
</div>
<div v-else class="space-y-2">
<div v-else class="space-y-3">
<!-- 按提供商分组 -->
<div
v-for="item in getMatchedKeysForMapping(mapping)"
:key="item.keyId"
class="bg-background rounded-md border p-3"
v-for="group in getMatchedKeysGroupedByProvider(mapping)"
:key="group.providerId"
class="bg-background rounded-md border overflow-hidden"
>
<div class="flex items-center gap-1.5 text-sm mb-2">
<span class="text-muted-foreground">{{ item.providerName }}</span>
<span class="text-muted-foreground">/</span>
<span class="font-medium">{{ item.keyName }}</span>
<span class="text-muted-foreground">·</span>
<code class="text-xs text-muted-foreground/70">{{ item.maskedKey }}</code>
</div>
<div class="flex flex-wrap gap-1">
<!-- 提供商标题 -->
<div class="px-3 py-2 bg-muted/30 border-b flex items-center justify-between">
<div>
<span class="text-sm font-medium">{{ group.providerName }}</span>
<span class="text-xs text-muted-foreground ml-2">({{ group.keys.length }} Key)</span>
</div>
<Badge
v-for="model in item.matchedModels"
:key="model"
v-if="group.isLinked"
variant="secondary"
class="text-xs font-mono"
class="text-xs"
>
{{ model }}
已关联
</Badge>
<Button
v-else
variant="ghost"
size="icon"
class="h-7 w-7"
title="关联到当前模型"
@click="$emit('linkProvider', group.providerId)"
>
<Link class="w-3.5 h-3.5" />
</Button>
</div>
<!-- Key 列表 -->
<div class="divide-y divide-border/50">
<div
v-for="keyItem in group.keys"
:key="keyItem.keyId"
class="px-3 py-2"
>
<div class="flex items-center gap-1.5 text-sm mb-1.5">
<span class="font-medium">{{ keyItem.keyName }}</span>
<code class="text-xs text-muted-foreground/70">{{ keyItem.maskedKey }}</code>
</div>
<div class="flex flex-wrap gap-1">
<Badge
v-for="model in keyItem.matchedModels"
:key="model"
variant="secondary"
class="text-xs font-mono"
>
{{ model }}
</Badge>
</div>
</div>
</div>
</div>
</div>
@@ -168,7 +199,7 @@
<script setup lang="ts">
import { ref, watch, onMounted, onUnmounted, computed } from 'vue'
import { Card, Button, Input, Badge } from '@/components/ui'
import { Plus, Trash2, GitMerge, RefreshCw, ChevronRight, Save, AlertCircle } from 'lucide-vue-next'
import { Plus, Trash2, GitMerge, RefreshCw, ChevronRight, Save, AlertCircle, Link } from 'lucide-vue-next'
import { updateGlobalModel, getGlobalModel, getGlobalModelRoutingPreview } from '@/api/global-models'
import type { ModelRoutingPreviewResponse } from '@/api/endpoints/types'
import { log } from '@/utils/logger'
@@ -183,6 +214,8 @@ const props = defineProps<{
const emit = defineEmits<{
update: [mappings: string[]]
refresh: []
linkProvider: [providerId: string]
linkProviders: [providerIds: string[]] // 批量关联
}>()
// 安全限制常量(与后端保持一致)
const MAX_MAPPINGS_PER_MODEL = 50
@@ -279,9 +312,17 @@ interface MatchedKeyForMapping {
keyName: string
maskedKey: string
providerName: string
providerId: string
matchedModels: string[]
}
interface ProviderGroup {
providerId: string
providerName: string
keys: MatchedKeyForMapping[]
isLinked: boolean // 是否已关联到当前模型
}
interface ValidationResult {
valid: boolean
error?: string
@@ -410,6 +451,7 @@ function getMatchedKeysForMapping(mapping: string): MatchedKeyForMapping[] {
keyName: keyItem.key_name,
maskedKey: keyItem.masked_key,
providerName: keyItem.provider_name,
providerId: keyItem.provider_id,
matchedModels,
})
}
@@ -419,6 +461,33 @@ function getMatchedKeysForMapping(mapping: string): MatchedKeyForMapping[] {
return Array.from(keyMap.values())
}
// 按提供商分组匹配的 Key
function getMatchedKeysGroupedByProvider(mapping: string): ProviderGroup[] {
const keys = getMatchedKeysForMapping(mapping)
const providerMap = new Map<string, ProviderGroup>()
// 获取已关联的提供商 ID 集合
const linkedProviderIds = new Set(
(routingData.value?.providers || []).map(p => p.id)
)
for (const key of keys) {
const existing = providerMap.get(key.providerId)
if (existing) {
existing.keys.push(key)
} else {
providerMap.set(key.providerId, {
providerId: key.providerId,
providerName: key.providerName,
keys: [key],
isLinked: linkedProviderIds.has(key.providerId),
})
}
}
return Array.from(providerMap.values())
}
// 获取指定映射的匹配数量
function getMatchCount(mapping: string): number {
return getMatchedKeysForMapping(mapping).reduce((sum, item) => sum + item.matchedModels.length, 0)
@@ -492,8 +561,26 @@ async function saveMappings() {
originalMappings.value = [...cleanedMappings] // 更新原始值
isDirty.value = false
toastSuccess('映射规则已保存')
emit('update', cleanedMappings)
// 收集所有未关联的提供商 ID
const unlinkedProviderIds: string[] = []
for (const mapping of cleanedMappings) {
const groups = getMatchedKeysGroupedByProvider(mapping)
for (const group of groups) {
if (!group.isLinked && !unlinkedProviderIds.includes(group.providerId)) {
unlinkedProviderIds.push(group.providerId)
}
}
}
// 自动关联未关联的提供商
if (unlinkedProviderIds.length > 0) {
toastSuccess(`映射规则已保存,正在关联 ${unlinkedProviderIds.length} 个提供商...`)
// linkProviders 处理完成后会由父组件统一刷新数据,无需再 emit update
emit('linkProviders', unlinkedProviderIds)
} else {
toastSuccess('映射规则已保存')
emit('update', cleanedMappings)
}
} catch (err) {
log.error('保存映射规则失败:', err)
toastError('保存失败,请重试')
@@ -526,4 +613,9 @@ onMounted(() => {
onUnmounted(() => {
regexCache.clear()
})
// 暴露刷新方法给父组件
defineExpose({
refresh: loadMatchPreview
})
</script>

View File

@@ -431,6 +431,8 @@
@delete-provider="confirmDeleteProviderImplementation"
@toggle-provider-status="toggleProviderStatus"
@refresh-model="refreshSelectedModel"
@link-provider="linkProviderToModel"
@link-providers="linkProvidersToModel"
/>
<!-- 批量添加关联提供商对话框 -->
@@ -953,11 +955,23 @@ function handleRowClick(event: MouseEvent, model: GlobalModelResponse) {
}
async function selectModel(model: GlobalModelResponse) {
// 先显示缓存数据,提升响应速度
selectedModel.value = model
detailTab.value = 'basic'
// 加载该模型的关联提供商
await loadModelProviders(model.id)
// 并行加载最新模型数据和关联提供商
const [latestModel] = await Promise.all([
getGlobalModel(model.id).catch(err => {
log.error('获取最新模型数据失败:', err)
return null
}),
loadModelProviders(model.id)
])
// 更新为最新数据(如果获取成功)
if (latestModel) {
selectedModel.value = latestModel
}
}
// 刷新当前选中的模型数据
@@ -1036,6 +1050,37 @@ function openAddProviderDialog() {
})
}
// 关联指定提供商到当前模型
async function linkProviderToModel(providerId: string) {
await linkProvidersToModel([providerId])
}
// 批量关联提供商到当前模型
async function linkProvidersToModel(providerIds: string[]) {
if (!selectedModel.value || providerIds.length === 0) return
try {
const result = await batchAssignToProviders(selectedModel.value.id, {
provider_ids: providerIds,
create_models: true
})
// 显示关联结果
if (result.errors.length > 0) {
showError(`${result.errors.length} 个提供商关联失败`, '部分失败')
} else {
success(`${providerIds.length} 个提供商已关联`)
}
// 刷新数据
await loadModelProviders(selectedModel.value.id)
await loadGlobalModels()
modelDetailDrawerRef.value?.refreshRoutingData?.()
} catch (err: any) {
showError(parseApiError(err, '关联失败'), '错误')
}
}
// 处理批量添加 Provider 对话框关闭事件
function handleBatchAddProvidersDialogUpdate(value: boolean) {
// 只有在不处于提交状态时才允许关闭

View File

@@ -390,6 +390,7 @@ def check_model_allowed_with_mappings(
# 获取 allowed_models 的集合
allowed_set = normalize_allowed_models(allowed_models)
if allowed_set is None:
# 不限制,已在 check_model_allowed 中返回 True
return True, None

View File

@@ -830,11 +830,6 @@ class CacheAwareScheduler:
# 查询该 Provider 是否有实现这个 GlobalModel
for model in provider.models:
if model.global_model_id == global_model.id and model.is_active:
logger.debug(
f"[_check_model_support_for_global_model] Provider={provider.name}, "
f"GlobalModel={global_model.name}, "
f"provider_model_name={model.provider_model_name}"
)
# 检查流式支持
if is_stream:
supports_streaming = model.get_effective_supports_streaming()