fix: 修复模型测试时正则映射支持及缓存装饰器实例方法兼容

- ModelsTab 加载时并行获取模型映射预览数据
- 测试模型时自动识别正则映射并使用映射名称和指定 key
- 密钥变更后并行刷新模型列表和模型映射
- 修复 cache_decorator 对实例方法的 context 参数解析
This commit is contained in:
fawney19
2026-01-15 17:26:52 +08:00
parent a223819dd7
commit 026f4b9c0e
3 changed files with 101 additions and 13 deletions

View File

@@ -383,6 +383,7 @@
<!-- 模型查看 --> <!-- 模型查看 -->
<ModelsTab <ModelsTab
v-if="provider" v-if="provider"
ref="modelsTabRef"
:key="`models-${provider.id}`" :key="`models-${provider.id}`"
:provider="provider" :provider="provider"
:endpoints="endpoints" :endpoints="endpoints"
@@ -585,6 +586,7 @@ const editingModel = ref<Model | null>(null)
const deleteModelConfirmOpen = ref(false) const deleteModelConfirmOpen = ref(false)
const modelToDelete = ref<Model | null>(null) const modelToDelete = ref<Model | null>(null)
const batchAssignDialogOpen = ref(false) const batchAssignDialogOpen = ref(false)
const modelsTabRef = ref<InstanceType<typeof ModelsTab> | null>(null)
const modelMappingTabRef = ref<InstanceType<typeof ModelMappingTab> | null>(null) const modelMappingTabRef = ref<InstanceType<typeof ModelMappingTab> | null>(null)
// 密钥列表拖拽排序状态 // 密钥列表拖拽排序状态
@@ -797,6 +799,11 @@ async function handleRecoverKey(key: EndpointAPIKey) {
async function handleKeyChanged() { async function handleKeyChanged() {
await loadEndpoints() await loadEndpoints()
// 并行刷新模型列表和模型映射(因为模型权限会影响正则映射预览)
await Promise.all([
modelsTabRef.value?.reload(),
modelMappingTabRef.value?.reload()
])
emit('refresh') emit('refresh')
} }

View File

@@ -211,7 +211,13 @@ import Card from '@/components/ui/card.vue'
import Button from '@/components/ui/button.vue' import Button from '@/components/ui/button.vue'
import { useToast } from '@/composables/useToast' import { useToast } from '@/composables/useToast'
import { useClipboard } from '@/composables/useClipboard' import { useClipboard } from '@/composables/useClipboard'
import { getProviderModels, type Model, testModel } from '@/api/endpoints' import {
getProviderModels,
getProviderMappingPreview,
testModel,
type Model,
type ProviderMappingPreviewResponse
} 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'
@@ -239,6 +245,7 @@ const { copyToClipboard } = useClipboard()
// 状态 // 状态
const loading = ref(false) const loading = ref(false)
const models = ref<Model[]>([]) const models = ref<Model[]>([])
const mappingPreview = ref<ProviderMappingPreviewResponse | null>(null)
const togglingModelId = ref<string | null>(null) const togglingModelId = ref<string | null>(null)
const testingModelId = ref<string | null>(null) const testingModelId = ref<string | null>(null)
const formatMenuModelId = ref<string | null>(null) const formatMenuModelId = ref<string | null>(null)
@@ -265,11 +272,19 @@ async function copyModelId(modelId: string) {
await copyToClipboard(modelId) await copyToClipboard(modelId)
} }
// 加载模型 // 加载模型和映射预览
async function loadModels() { async function loadModels() {
try { try {
loading.value = true loading.value = true
models.value = await getProviderModels(props.provider.id) const [modelsData, previewData] = await Promise.all([
getProviderModels(props.provider.id),
getProviderMappingPreview(props.provider.id).catch((err) => {
console.warn('Failed to load mapping preview:', err)
return null
})
])
models.value = modelsData
mappingPreview.value = previewData
} catch (err: any) { } catch (err: any) {
showError(err.response?.data?.detail || '加载失败', '错误') showError(err.response?.data?.detail || '加载失败', '错误')
} finally { } finally {
@@ -382,6 +397,31 @@ async function toggleModelActive(model: Model) {
} }
} }
// 查找模型的正则映射信息(返回第一个匹配的活跃 key 和映射名称)
function findRegexMapping(model: Model): { keyId: string; mappedName: string } | null {
if (!mappingPreview.value) return null
// 在映射预览中查找该模型的全局模型 ID
const globalModelId = model.global_model_id
if (!globalModelId) return null
for (const keyInfo of mappingPreview.value.keys) {
// 跳过未激活的 key
if (!keyInfo.is_active) continue
for (const gm of keyInfo.matching_global_models) {
if (gm.global_model_id === globalModelId && gm.matched_models.length > 0) {
// 返回第一个匹配的映射名称
return {
keyId: keyInfo.key_id,
mappedName: gm.matched_models[0].allowed_model
}
}
}
}
return null
}
// 测试模型连接性 // 测试模型连接性
async function testModelConnection(model: Model, apiFormat?: string) { async function testModelConnection(model: Model, apiFormat?: string) {
if (testingModelId.value) return if (testingModelId.value) return
@@ -389,11 +429,17 @@ async function testModelConnection(model: Model, apiFormat?: string) {
testingModelId.value = model.id testingModelId.value = model.id
formatMenuModelId.value = null formatMenuModelId.value = null
try { try {
// 检查是否有正则映射,如果有则使用映射名称和指定 key
const regexMapping = findRegexMapping(model)
const modelName = regexMapping?.mappedName || model.provider_model_name
const apiKeyId = regexMapping?.keyId
const result = await testModel({ const result = await testModel({
provider_id: props.provider.id, provider_id: props.provider.id,
model_name: model.provider_model_name, model_name: modelName,
message: "hello", message: "hello",
api_format: apiFormat api_format: apiFormat,
api_key_id: apiKeyId
}) })
if (result.success) { if (result.success) {
@@ -404,7 +450,7 @@ async function testModelConnection(model: Model, apiFormat?: string) {
} else if (result.data?.content_preview) { } else if (result.data?.content_preview) {
showSuccess(`流式测试成功,预览: ${result.data.content_preview}`) showSuccess(`流式测试成功,预览: ${result.data.content_preview}`)
} else { } else {
showSuccess(`模型 "${model.provider_model_name}" 测试成功`) showSuccess(`模型 "${modelName}" 测试成功`)
} }
} else { } else {
showError(`模型测试失败: ${parseTestModelError(result)}`) showError(`模型测试失败: ${parseTestModelError(result)}`)
@@ -447,4 +493,9 @@ onMounted(() => {
onUnmounted(() => { onUnmounted(() => {
document.removeEventListener('click', handleClickOutside) document.removeEventListener('click', handleClickOutside)
}) })
// 暴露给父组件
defineExpose({
reload: loadModels
})
</script> </script>

View File

@@ -2,13 +2,28 @@
import functools import functools
import json import json
from typing import Any, Callable, Optional from typing import Any, Callable
from src.core.logger import logger from src.core.logger import logger
from src.clients.redis_client import get_redis_client_sync from src.clients.redis_client import get_redis_client_sync
def _is_adapter_instance(obj: Any) -> bool:
"""检查对象是否是 ApiAdapter 的实例(延迟导入避免循环依赖)"""
try:
from src.api.base.adapter import ApiAdapter
return isinstance(obj, ApiAdapter)
except ImportError:
return False
def _is_api_context(obj: Any) -> bool:
"""检查对象是否是 ApiRequestContext通过 duck typing"""
return hasattr(obj, "user") and hasattr(obj, "db")
def cache_result(key_prefix: str, ttl: int = 60, user_specific: bool = True) -> Callable: def cache_result(key_prefix: str, ttl: int = 60, user_specific: bool = True) -> Callable:
""" """
缓存函数结果的装饰器 缓存函数结果的装饰器
@@ -30,8 +45,23 @@ def cache_result(key_prefix: str, ttl: int = 60, user_specific: bool = True) ->
# 构建缓存键 # 构建缓存键
try: try:
# 从 args 中获取 context(通常是第一个参数) # 从 args 中获取 context
context = args[0] if args else None # 对于实例方法args[0] 是 selfargs[1] 才是 context
# 对于普通函数args[0] 是 context
context = None
adapter_self = None
if len(args) >= 2 and _is_adapter_instance(args[0]) and _is_api_context(args[1]):
# 实例方法: handle(self, context)
adapter_self = args[0]
context = args[1]
elif len(args) >= 1 and _is_api_context(args[0]):
# 普通函数或 context 在第一个位置
context = args[0]
elif len(args) >= 1 and _is_adapter_instance(args[0]):
# 实例方法但 context 可能在 kwargs 中
adapter_self = args[0]
context = kwargs.get("context")
if user_specific and context and hasattr(context, "user") and context.user: if user_specific and context and hasattr(context, "user") and context.user:
cache_key = f"{key_prefix}:user:{context.user.id}" cache_key = f"{key_prefix}:user:{context.user.id}"
@@ -39,11 +69,11 @@ def cache_result(key_prefix: str, ttl: int = 60, user_specific: bool = True) ->
cache_key = f"{key_prefix}:global" cache_key = f"{key_prefix}:global"
# 如果有额外的参数(如 days添加到键中 # 如果有额外的参数(如 days添加到键中
if hasattr(args[0], "__dict__"): # 从 adapter_self 获取dataclass 属性)
# 如果是 dataclass 或对象,获取其属性 if adapter_self and hasattr(adapter_self, "__dict__"):
for attr_name in ["days", "limit"]: for attr_name in ["days", "limit"]:
if hasattr(args[0], attr_name): if hasattr(adapter_self, attr_name):
attr_value = getattr(args[0], attr_name) attr_value = getattr(adapter_self, attr_name)
cache_key += f":{attr_name}:{attr_value}" cache_key += f":{attr_name}:{attr_value}"
# 尝试从缓存获取 # 尝试从缓存获取