refactor: 统一测试请求构建逻辑,支持多格式测试

- 重构 adapter 基类的 build_request_body 方法,使用 converter_registry 自动处理格式转换
- 后端 test_model 接口增加 endpoint_id 和 api_format 参数支持
- 前端模型映射测试支持根据 Key 和端点配置动态显示可用格式下拉菜单
This commit is contained in:
fawney19
2026-01-22 23:25:51 +08:00
parent 6c7b40764f
commit 1da70d0518
12 changed files with 264 additions and 85 deletions

View File

@@ -1,6 +1,9 @@
import client from '../client' import client from '../client'
import type { EndpointAPIKey, AllowedModels } from './types' import type { EndpointAPIKey, AllowedModels } from './types'
// Re-export types for convenience
export type { EndpointAPIKey, AllowedModels }
/** /**
* 能力定义类型 * 能力定义类型
*/ */

View File

@@ -68,6 +68,7 @@ export interface TestModelRequest {
provider_id: string provider_id: string
model_name: string model_name: string
api_key_id?: string api_key_id?: string
endpoint_id?: string
message?: string message?: string
api_format?: string api_format?: string
} }

View File

@@ -150,13 +150,47 @@
<span class="font-mono text-sm truncate"> <span class="font-mono text-sm truncate">
{{ mapping.name }} {{ mapping.name }}
</span> </span>
<!-- 测试按钮支持多格式选择 -->
<DropdownMenu
v-if="getItemAvailableFormats(item).length > 1"
v-model:open="formatMenuOpen[`${item.key}-${mapping.name}`]"
>
<DropdownMenuTrigger as-child>
<Button
variant="ghost"
size="icon"
class="h-7 w-7 shrink-0"
title="测试映射"
:disabled="testingMapping === `${item.key}-${mapping.name}`"
>
<Loader2
v-if="testingMapping === `${item.key}-${mapping.name}`"
class="w-3 h-3 animate-spin"
/>
<Play
v-else
class="w-3 h-3"
/>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
v-for="fmt in getItemAvailableFormats(item)"
:key="fmt"
@select="testMapping(item, mapping, fmt)"
>
{{ fmt }}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Button <Button
v-else
variant="ghost" variant="ghost"
size="icon" size="icon"
class="h-7 w-7 shrink-0" class="h-7 w-7 shrink-0"
title="测试映射" title="测试映射"
:disabled="testingMapping === `${item.key}-${mapping.name}`" :disabled="testingMapping === `${item.key}-${mapping.name}` || getItemAvailableFormats(item).length === 0"
@click="testMapping(item, mapping)" @click="testMapping(item, mapping, getItemAvailableFormats(item)[0])"
> >
<Loader2 <Loader2
v-if="testingMapping === `${item.key}-${mapping.name}`" v-if="testingMapping === `${item.key}-${mapping.name}`"
@@ -281,7 +315,10 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, watch } from 'vue' import { ref, computed, watch } from 'vue'
import { Tag, Plus, Edit, Trash2, ChevronRight, Loader2, Play } from 'lucide-vue-next' import { Tag, Plus, Edit, Trash2, ChevronRight, Loader2, Play } from 'lucide-vue-next'
import { Card, Button, Badge } from '@/components/ui' import {
Card, Button, Badge,
DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem
} from '@/components/ui'
import AlertDialog from '@/components/common/AlertDialog.vue' import AlertDialog from '@/components/common/AlertDialog.vue'
import ModelMappingDialog, { type AliasGroup } from '../ModelMappingDialog.vue' import ModelMappingDialog, { type AliasGroup } from '../ModelMappingDialog.vue'
import { useToast } from '@/composables/useToast' import { useToast } from '@/composables/useToast'
@@ -293,6 +330,9 @@ import {
type ProviderModelAlias, type ProviderModelAlias,
type ProviderMappingPreviewResponse type ProviderMappingPreviewResponse
} from '@/api/endpoints' } from '@/api/endpoints'
import { getProviderEndpoints } from '@/api/endpoints/endpoints'
import { getProviderKeys, type EndpointAPIKey } from '@/api/endpoints/keys'
import type { ProviderEndpoint } from '@/api/endpoints/types'
import { updateModel } from '@/api/endpoints/models' import { updateModel } from '@/api/endpoints/models'
import { parseTestModelError } from '@/utils/errorParser' import { parseTestModelError } from '@/utils/errorParser'
@@ -341,6 +381,15 @@ const deletingGroup = ref<AliasGroup | null>(null)
const testingMapping = ref<string | null>(null) const testingMapping = ref<string | null>(null)
const preselectedModelId = ref<string | null>(null) const preselectedModelId = ref<string | null>(null)
// 端点数据(用于测试格式选择)
const providerEndpoints = ref<ProviderEndpoint[]>([])
// Key 数据(用于判断支持的格式)
const providerKeys = ref<EndpointAPIKey[]>([])
// 测试下拉菜单状态
const formatMenuOpen = ref<Record<string, boolean>>({})
// 展开状态 // 展开状态
const expandedItems = ref<Set<number>>(new Set()) const expandedItems = ref<Set<number>>(new Set())
@@ -470,12 +519,16 @@ const combinedMappings = computed<CombinedMapping[]>(() => {
async function loadData() { async function loadData() {
try { try {
loading.value = true loading.value = true
const [modelsData, previewData] = await Promise.all([ const [modelsData, previewData, endpointsData, keysData] = await Promise.all([
getProviderModels(props.provider.id), getProviderModels(props.provider.id),
getProviderMappingPreview(props.provider.id).catch(() => null) getProviderMappingPreview(props.provider.id).catch(() => null),
getProviderEndpoints(props.provider.id).catch(() => []),
getProviderKeys(props.provider.id).catch(() => [])
]) ])
models.value = modelsData models.value = modelsData
aliasMappingPreview.value = previewData aliasMappingPreview.value = previewData
providerEndpoints.value = endpointsData
providerKeys.value = keysData
} catch (err: any) { } catch (err: any) {
showError(err.response?.data?.detail || '加载失败', '错误') showError(err.response?.data?.detail || '加载失败', '错误')
} finally { } finally {
@@ -566,16 +619,69 @@ async function onDialogSaved() {
emit('refresh') emit('refresh')
} }
// 测试模型映射(精确映射 // 获取可用的 API 格式(有活跃端点,去重
async function testMapping(item: CombinedMapping, mapping: MappingItem) { const availableApiFormats = computed(() => {
const formats = new Set(
providerEndpoints.value
.filter(ep => ep.is_active)
.map(ep => ep.api_format)
)
return [...formats]
})
// 获取映射项支持的 API 格式
// 逻辑:找到支持该映射格式的所有活跃 Key获取这些 Key 支持的所有格式,与活跃端点格式取交集
function getItemAvailableFormats(item: CombinedMapping): string[] {
// 精确映射:基于 group.apiFormats 筛选
if (item.type === 'exact' && item.group?.apiFormats && item.group.apiFormats.length > 0) {
const mappingFormats = item.group.apiFormats
// 找到所有支持该映射格式的活跃 Key
const supportingKeys = providerKeys.value.filter(key => {
if (!key.is_active) return false
// Key 的 api_formats 与映射的 apiFormats 有交集
return key.api_formats?.some(fmt => mappingFormats.includes(fmt))
})
if (supportingKeys.length === 0) {
return []
}
// 收集这些 Key 支持的所有格式
const keyFormats = new Set<string>()
for (const key of supportingKeys) {
for (const fmt of key.api_formats || []) {
keyFormats.add(fmt)
}
}
// 与活跃端点格式取交集
return availableApiFormats.value.filter(fmt => keyFormats.has(fmt))
}
// 正则映射或无限制:返回所有有活跃 Key 支持的端点格式
const allKeyFormats = new Set<string>()
for (const key of providerKeys.value) {
if (!key.is_active) continue
for (const fmt of key.api_formats || []) {
allKeyFormats.add(fmt)
}
}
return availableApiFormats.value.filter(fmt => allKeyFormats.has(fmt))
}
// 测试精确映射(直接发请求或显示下拉菜单选择格式)
async function testMapping(item: CombinedMapping, mapping: MappingItem, apiFormat?: string) {
const testingKey = `${item.key}-${mapping.name}` const testingKey = `${item.key}-${mapping.name}`
testingMapping.value = testingKey testingMapping.value = testingKey
formatMenuOpen.value[testingKey] = false
try { try {
const result = await testModel({ const result = await testModel({
provider_id: props.provider.id, provider_id: props.provider.id,
model_name: mapping.name, model_name: mapping.name,
message: "hello" message: "hello",
api_format: apiFormat
}) })
if (result.success) { if (result.success) {
@@ -591,7 +697,7 @@ async function testMapping(item: CombinedMapping, mapping: MappingItem) {
} }
} }
// 测试正则映射(指定 Key // 测试正则映射(指定 Key,直接发请求,因为已经有 Key 信息
async function testRegexMapping(item: CombinedMapping, keyItem: MatchedKeyInfo, match: MappingItem) { async function testRegexMapping(item: CombinedMapping, keyItem: MatchedKeyInfo, match: MappingItem) {
const testingKey = `${item.key}-${keyItem.keyId}-${match.name}` const testingKey = `${item.key}-${keyItem.keyId}-${match.name}`
testingMapping.value = testingKey testingMapping.value = testingKey

View File

@@ -45,6 +45,7 @@ class TestModelRequest(BaseModel):
provider_id: str provider_id: str
model_name: str model_name: str
api_key_id: Optional[str] = None api_key_id: Optional[str] = None
endpoint_id: Optional[str] = None # 指定使用的端点ID
stream: bool = False stream: bool = False
message: Optional[str] = "你好" message: Optional[str] = "你好"
api_format: Optional[str] = None # 指定使用的API格式如果不指定则使用端点的默认格式 api_format: Optional[str] = None # 指定使用的API格式如果不指定则使用端点的默认格式
@@ -200,17 +201,73 @@ async def test_model(
if not provider: if not provider:
raise HTTPException(status_code=404, detail="Provider not found") raise HTTPException(status_code=404, detail="Provider not found")
# 构建 api_format -> endpoint 映射 # 构建 api_format -> endpoint 映射 和 id -> endpoint 映射
format_to_endpoint: dict[str, ProviderEndpoint] = {} format_to_endpoint: dict[str, ProviderEndpoint] = {}
id_to_endpoint: dict[str, ProviderEndpoint] = {}
for ep in provider.endpoints: for ep in provider.endpoints:
if ep.is_active: if ep.is_active:
format_to_endpoint[ep.api_format] = ep format_to_endpoint[ep.api_format] = ep
id_to_endpoint[ep.id] = ep
# 找到合适的端点和 API Key # 找到合适的端点和 API Key
endpoint = None endpoint = None
api_key = None api_key = None
if request.api_key_id: # 优先级: api_format > endpoint_id > api_key_id > 自动选择
# 如果指定了 api_format优先使用该格式对应的 endpoint
if request.api_format:
endpoint = format_to_endpoint.get(request.api_format)
if not endpoint:
raise HTTPException(
status_code=404,
detail=f"No active endpoint found for API format: {request.api_format}"
)
if request.api_key_id:
# 使用指定的 Key但需要校验是否支持该格式
api_key = next(
(key for key in provider.api_keys if key.id == request.api_key_id and key.is_active),
None
)
if api_key and request.api_format not in (api_key.api_formats or []):
raise HTTPException(
status_code=400,
detail=f"API Key does not support format: {request.api_format}"
)
else:
# 找支持该格式的第一个可用 Key
for key in provider.api_keys:
if not key.is_active:
continue
if request.api_format in (key.api_formats or []):
api_key = key
break
elif request.endpoint_id:
# 使用指定的端点
endpoint = id_to_endpoint.get(request.endpoint_id)
if not endpoint:
raise HTTPException(status_code=404, detail="Endpoint not found or not active")
if request.api_key_id:
# 同时指定了 Key需要校验是否支持该端点格式
api_key = next(
(key for key in provider.api_keys if key.id == request.api_key_id and key.is_active),
None
)
if api_key and endpoint.api_format not in (api_key.api_formats or []):
raise HTTPException(
status_code=400,
detail=f"API Key does not support endpoint format: {endpoint.api_format}"
)
else:
# 找支持该端点格式的第一个可用 Key
for key in provider.api_keys:
if not key.is_active:
continue
if endpoint.api_format in (key.api_formats or []):
api_key = key
break
elif request.api_key_id:
# 使用指定的 API Key # 使用指定的 API Key
api_key = next( api_key = next(
(key for key in provider.api_keys if key.id == request.api_key_id and key.is_active), (key for key in provider.api_keys if key.id == request.api_key_id and key.is_active),
@@ -274,24 +331,6 @@ async def test_model(
logger.debug(f"[test-model] 使用 Adapter: {adapter_class.__name__}") logger.debug(f"[test-model] 使用 Adapter: {adapter_class.__name__}")
logger.debug(f"[test-model] 端点 API Format: {endpoint.api_format}") logger.debug(f"[test-model] 端点 API Format: {endpoint.api_format}")
# 如果请求指定了 api_format优先使用它
target_api_format = request.api_format or endpoint.api_format
if request.api_format and request.api_format != endpoint.api_format:
logger.debug(f"[test-model] 请求指定 API Format: {request.api_format}")
# 重新获取适配器
adapter_class = _get_adapter_for_format(request.api_format)
if not adapter_class:
return {
"success": False,
"error": f"Unknown API format: {request.api_format}",
"provider": {
"id": provider.id,
"name": provider.name,
},
"model": request.model_name,
}
logger.debug(f"[test-model] 重新选择 Adapter: {adapter_class.__name__}")
# 准备测试请求数据 # 准备测试请求数据
check_request = { check_request = {
"model": request.model_name, "model": request.model_name,

View File

@@ -107,10 +107,18 @@ class ChatAdapterBase(ApiAdapter):
return build_adapter_headers(cls._get_api_format(), api_key, extra_headers) return build_adapter_headers(cls._get_api_format(), api_key, extra_headers)
@classmethod @classmethod
def build_request_body(cls, request_data: Dict[str, Any]) -> Dict[str, Any]: def build_request_body(cls, request_data: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
"""构建请求体,子类可以覆盖以自定义请求格式转换""" """构建测试请求体,使用转换器注册表自动处理格式转换
# 默认实现:直接使用请求数据
return request_data.copy() Args:
request_data: 可选的请求数据,会与默认测试请求合并
Returns:
转换为目标 API 格式的请求体
"""
from src.api.handlers.base.request_builder import build_test_request_body
return build_test_request_body(cls.FORMAT_ID, request_data)
def extract_api_key(self, request: Request) -> Optional[str]: def extract_api_key(self, request: Request) -> Optional[str]:
"""从请求中提取 API 密钥,使用统一的 headers.py 实现""" """从请求中提取 API 密钥,使用统一的 headers.py 实现"""

View File

@@ -684,17 +684,18 @@ class CliAdapterBase(ApiAdapter):
raise NotImplementedError(f"{cls.FORMAT_ID} adapter must implement build_endpoint_url") raise NotImplementedError(f"{cls.FORMAT_ID} adapter must implement build_endpoint_url")
@classmethod @classmethod
def build_request_body(cls, request_data: Dict[str, Any]) -> Dict[str, Any]: def build_request_body(cls, request_data: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
""" """构建测试请求体,使用转换器注册表自动处理格式转换
构建CLI API请求体 - 子类应覆盖
Args: Args:
request_data: 请求数据 request_data: 可选的请求数据,会与默认测试请求合并
Returns: Returns:
请求体字典 转换为目标 API 格式的请求体
""" """
raise NotImplementedError(f"{cls.FORMAT_ID} adapter must implement build_request_body") from src.api.handlers.base.request_builder import build_test_request_body
return build_test_request_body(cls.FORMAT_ID, request_data)
@classmethod @classmethod
def get_cli_user_agent(cls) -> Optional[str]: def get_cli_user_agent(cls) -> Optional[str]:

View File

@@ -27,6 +27,67 @@ from src.core.api_format import HeaderBuilder, UPSTREAM_DROP_HEADERS
SENSITIVE_HEADERS: FrozenSet[str] = UPSTREAM_DROP_HEADERS SENSITIVE_HEADERS: FrozenSet[str] = UPSTREAM_DROP_HEADERS
# ==============================================================================
# 测试请求常量与辅助函数
# ==============================================================================
# 标准测试请求体OpenAI 格式)
# 用于 check_endpoint 等测试场景,使用简单安全的消息内容避免触发安全过滤
DEFAULT_TEST_REQUEST: Dict[str, Any] = {
"messages": [{"role": "user", "content": "Hi"}],
"max_tokens": 5,
"temperature": 0,
}
def get_test_request_data(request_data: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
"""获取测试请求数据
如果传入 request_data则合并到默认测试请求中
否则使用默认测试请求。
Args:
request_data: 用户提供的请求数据(会覆盖默认值)
Returns:
合并后的测试请求数据OpenAI 格式)
"""
if request_data:
merged = DEFAULT_TEST_REQUEST.copy()
merged.update(request_data)
return merged
return DEFAULT_TEST_REQUEST.copy()
def build_test_request_body(
format_id: str,
request_data: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
"""构建测试请求体,自动处理格式转换
使用 converter_registry 将 OpenAI 格式的测试请求转换为目标格式。
Args:
format_id: 目标 API 格式 ID"CLAUDE", "GEMINI", "OPENAI_CLI"
request_data: 可选的请求数据,会与默认测试请求合并
Returns:
转换为目标 API 格式的请求体
"""
from src.core.api_format.conversion import converter_registry
from src.core.api_format.utils import get_base_format
# 获取测试请求数据OpenAI 格式)
source_data = get_test_request_data(request_data)
# CLI 格式使用基础格式进行转换CLAUDE_CLI -> CLAUDE
# 因为 converter_registry 只注册了基础格式之间的转换器
target_format = get_base_format(format_id) or format_id
# 使用注册表进行格式转换 (OPENAI -> 目标基础格式)
return converter_registry.convert_request(source_data, "OPENAI", target_format)
# ============================================================================== # ==============================================================================
# 请求构建器 # 请求构建器
# ============================================================================== # ==============================================================================

View File

@@ -198,14 +198,7 @@ class ClaudeChatAdapter(ChatAdapterBase):
else: else:
return f"{base_url}/v1/messages" return f"{base_url}/v1/messages"
@classmethod # build_request_body 使用基类实现,通过 converter_registry 自动转换 OPENAI -> CLAUDE
def build_request_body(cls, request_data: Dict[str, Any]) -> Dict[str, Any]:
"""构建Claude API请求体"""
return {
"model": request_data.get("model"),
"max_tokens": request_data.get("max_tokens", 100),
"messages": request_data.get("messages", []),
}
def build_claude_adapter(x_app_header: Optional[str]): def build_claude_adapter(x_app_header: Optional[str]):

View File

@@ -128,14 +128,7 @@ class ClaudeCliAdapter(CliAdapterBase):
else: else:
return f"{base_url}/v1/messages" return f"{base_url}/v1/messages"
@classmethod # build_request_body 使用基类实现,通过 converter_registry 自动转换 OPENAI -> CLAUDE_CLI
def build_request_body(cls, request_data: Dict[str, Any]) -> Dict[str, Any]:
"""构建Claude CLI API请求体"""
return {
"model": request_data.get("model"),
"max_tokens": request_data.get("max_tokens", 100),
"messages": request_data.get("messages", []),
}
@classmethod @classmethod
def get_cli_user_agent(cls) -> Optional[str]: def get_cli_user_agent(cls) -> Optional[str]:

View File

@@ -223,19 +223,7 @@ class GeminiChatAdapter(ChatAdapterBase):
else: else:
return f"{base_url}/v1beta" return f"{base_url}/v1beta"
@classmethod # build_request_body 使用基类实现,通过 converter_registry 自动转换 OPENAI -> GEMINI
def build_request_body(cls, request_data: Dict[str, Any]) -> Dict[str, Any]:
"""构建Gemini API请求体"""
return {
"contents": request_data.get("messages", []),
"generationConfig": {
"maxOutputTokens": request_data.get("max_tokens", 100),
"temperature": request_data.get("temperature", 0.7),
},
"safetySettings": [
{"category": "HARM_CATEGORY_HARASSMENT", "threshold": "BLOCK_NONE"}
],
}
@classmethod @classmethod
async def check_endpoint( async def check_endpoint(

View File

@@ -149,19 +149,7 @@ class GeminiCliAdapter(CliAdapterBase):
prefix = f"{base_url}/v1beta" prefix = f"{base_url}/v1beta"
return f"{prefix}/models/{effective_model_name}:generateContent" return f"{prefix}/models/{effective_model_name}:generateContent"
@classmethod # build_request_body 使用基类实现,通过 converter_registry 自动转换 OPENAI -> GEMINI_CLI
def build_request_body(cls, request_data: Dict[str, Any]) -> Dict[str, Any]:
"""构建Gemini CLI API请求体"""
return {
"contents": request_data.get("messages", []),
"generationConfig": {
"maxOutputTokens": request_data.get("max_tokens", 100),
"temperature": request_data.get("temperature", 0.7),
},
"safetySettings": [
{"category": "HARM_CATEGORY_HARASSMENT", "threshold": "BLOCK_NONE"}
],
}
@classmethod @classmethod
def get_cli_user_agent(cls) -> Optional[str]: def get_cli_user_agent(cls) -> Optional[str]:

View File

@@ -70,10 +70,8 @@ class OpenAICliAdapter(CliAdapterBase):
else: else:
return f"{base_url}/v1/chat/completions" return f"{base_url}/v1/chat/completions"
@classmethod # build_request_body 使用基类实现
def build_request_body(cls, request_data: Dict[str, Any]) -> Dict[str, Any]: # OPENAI -> OPENAI_CLI 无转换器,会直接透传原始请求
"""构建OpenAI CLI API请求体"""
return request_data.copy()
@classmethod @classmethod
def get_cli_user_agent(cls) -> Optional[str]: def get_cli_user_agent(cls) -> Optional[str]: