fix(provider): support multi-key selection in model tests

This commit is contained in:
zhefox
2026-05-26 13:08:35 +08:00
parent 5fc6dc8019
commit 0bf63cc80e
7 changed files with 253 additions and 26 deletions
@@ -99,6 +99,33 @@
</div>
</div>
<div
v-if="showKeySelector"
class="space-y-2"
>
<div class="flex items-center justify-between gap-3">
<div class="text-sm font-medium text-foreground">
测试 Key
</div>
<div class="text-xs text-muted-foreground">
{{ keySelectionStatus }}
</div>
</div>
<MultiSelect
:model-value="selectedKeyIds"
:options="keyOptions"
:placeholder="keySelectorPlaceholder"
search-placeholder="搜索 Key"
empty-text="暂无可选 Key"
no-results-text="未找到匹配 Key"
trigger-class="h-9 min-h-9 rounded-md border-border/60 text-xs"
dropdown-min-width="24rem"
:search-threshold="0"
:disabled="keyOptionsLoading && keyOptions.length === 0"
@update:model-value="emit('update:selectedKeyIds', $event)"
/>
</div>
<div class="grid gap-4 lg:grid-cols-2 lg:items-start">
<div class="space-y-2">
<div class="flex items-center justify-between gap-3">
@@ -772,9 +799,11 @@ import {
} from '@/components/ui'
import Button from '@/components/ui/button.vue'
import Textarea from '@/components/ui/textarea.vue'
import MultiSelect from '@/components/common/MultiSelect.vue'
import { formatApiFormat } from '@/api/endpoints/types/api-format'
import type { TestAttemptDetail, TestCandidateSummary, TestModelFailoverResponse } from '@/api/endpoints/providers'
import type { CandidateRecord, RequestTrace } from '@/api/requestTrace'
import type { MultiSelectOption } from '@/components/common/MultiSelect.vue'
import JsonContent from '@/features/usage/components/RequestDetailDrawer/JsonContent.vue'
import { useClipboard } from '@/composables/useClipboard'
import { useDarkMode } from '@/composables/useDarkMode'
@@ -797,6 +826,8 @@ type TestModelMappingOption = {
priority?: number
}
type TestKeyOption = MultiSelectOption
const props = defineProps<{
open: boolean
result: TestModelFailoverResponse | null
@@ -818,6 +849,9 @@ const props = defineProps<{
modelMappingAvailable?: boolean
modelMappingOptions?: TestModelMappingOption[]
selectedModelMapping?: string | null
keyOptions?: TestKeyOption[]
selectedKeyIds?: string[]
keyOptionsLoading?: boolean
startDisabled?: boolean
}>()
@@ -827,15 +861,30 @@ const emit = defineEmits<{
start: []
selectEndpoint: [endpointId: string]
selectModelMapping: [modelName: string]
'update:selectedKeyIds': [value: string[]]
'update:requestHeadersDraft': [value: string]
'update:requestBodyDraft': [value: string]
}>()
const endpoints = computed(() => props.endpoints ?? [])
const modelMappingOptions = computed(() => props.modelMappingOptions ?? [])
const keyOptions = computed(() => props.keyOptions ?? [])
const selectedKeyIds = computed(() => props.selectedKeyIds ?? [])
const keyOptionsLoading = computed(() => props.keyOptionsLoading === true)
const modelMappingAvailable = computed(
() => props.modelMappingAvailable === true && modelMappingOptions.value.length > 0,
)
const showKeySelector = computed(() => (
keyOptionsLoading.value || keyOptions.value.length > 0 || selectedKeyIds.value.length > 0
))
const keySelectorPlaceholder = computed(() => (
keyOptionsLoading.value && keyOptions.value.length === 0 ? '正在加载 Key' : '默认调度(不指定 Key)'
))
const keySelectionStatus = computed(() => {
if (selectedKeyIds.value.length > 0) return `已选 ${selectedKeyIds.value.length}`
if (keyOptionsLoading.value) return '加载中'
return '默认'
})
const requestedModelName = computed(() => props.requestedModelName?.trim() || '')
const selectedModelMapping = computed(() => props.selectedModelMapping?.trim() || '')
const selectedModelMappingValue = computed(() => (
@@ -232,12 +232,16 @@
:model-mapping-available="testModelMappingAvailable"
:model-mapping-options="testModelMappingOptions"
:selected-model-mapping="selectedTestMappedModelName"
:key-options="testKeyOptions"
:selected-key-ids="selectedTestKeyIds"
:key-options-loading="loadingModelTestKeys"
:start-disabled="!selectedTestEndpoint || !!testRequestHeadersError || !!testRequestBodyError"
@close="handleTestDialogClose"
@back="handleTestDialogBack"
@start="handleStartPendingTest"
@select-endpoint="handleSelectTestEndpoint"
@select-model-mapping="handleSelectModelMapping"
@update:selected-key-ids="handleSelectTestKeyIds"
@update:request-headers-draft="testRequestHeadersDraft = $event"
@update:request-body-draft="testRequestBodyDraft = $event"
/>
@@ -257,7 +261,7 @@ import {
type Model,
type ProviderEndpoint,
} from '@/api/endpoints'
import { type EndpointAPIKey } from '@/api/endpoints/keys'
import { getProviderKeys, type EndpointAPIKey } from '@/api/endpoints/keys'
import { updateModel } from '@/api/endpoints/models'
import { parseApiError } from '@/utils/errorParser'
import { formatApiFormat } from '@/api/endpoints/types/api-format'
@@ -269,6 +273,7 @@ import {
isModelTestableApiFormat,
isModelTestableEndpoint,
listModelTestMappedModelOptions,
modelTestKeySupportsEndpoint,
normalizeModelTestMappedModelSelection,
parseModelTestRequestHeadersDraft,
parseModelTestRequestBodyDraft,
@@ -307,6 +312,10 @@ const testRequestHeadersResetValue = ref('')
const testRequestBodyDraft = ref('')
const testRequestBodyResetValue = ref('')
const selectedTestMappedModelName = ref<string | null>(null)
const selectedTestKeyIds = ref<string[]>([])
const modelTestProviderKeys = ref<EndpointAPIKey[]>([])
const modelTestKeysLoadedProviderId = ref<string | null>(null)
const loadingModelTestKeys = ref(false)
const isPoolManagedProvider = computed(() => Boolean(props.provider.pool_advanced))
const activeEndpoints = computed(() => (props.endpoints ?? [])
.filter(endpoint => {
@@ -336,6 +345,32 @@ const mappedTestModelName = computed(() => {
: null
})
const testModelMappingAvailable = computed(() => testModelMappingOptions.value.length > 0)
const providerKeysForModelTest = computed(() => (
modelTestKeysLoadedProviderId.value === props.provider.id
? modelTestProviderKeys.value
: props.providerKeys ?? []
))
const testKeyOptions = computed(() => {
const endpoint = selectedTestEndpoint.value
if (!endpoint) return []
const seen = new Set<string>()
return [...providerKeysForModelTest.value]
.filter((key) => {
if (seen.has(key.id)) return false
seen.add(key.id)
return modelTestKeySupportsEndpoint(key, endpoint, props.provider.provider_type)
})
.sort((left, right) => {
const priority = left.internal_priority - right.internal_priority
if (priority !== 0) return priority
return formatTestKeyOptionLabel(left).localeCompare(formatTestKeyOptionLabel(right))
})
.map(key => ({
value: key.id,
label: formatTestKeyOptionLabel(key),
}))
})
const effectiveTestRequestModelName = computed(() => (
mappedTestModelName.value || pendingRequestedModelName.value
))
@@ -506,6 +541,7 @@ function handleTestDialogClose() {
pendingTestModel.value = null
selectedTestEndpoint.value = null
selectedTestMappedModelName.value = null
selectedTestKeyIds.value = []
testRequestHeadersDraft.value = ''
testRequestHeadersResetValue.value = ''
testRequestBodyDraft.value = ''
@@ -524,6 +560,7 @@ function handleSelectTestEndpoint(endpointId: string) {
selectedTestEndpoint.value = endpoint
syncSelectedTestModelMapping()
resetTestRequestBodyForSelectedEndpoint()
pruneSelectedTestKeyIds()
}
function handleSelectModelMapping(modelName: string) {
@@ -534,6 +571,10 @@ function handleSelectModelMapping(modelName: string) {
syncTestRequestBodyModel()
}
function handleSelectTestKeyIds(ids: string[]) {
selectedTestKeyIds.value = normalizeSelectedTestKeyIds(ids)
}
async function handleStartPendingTest() {
if (modelTest.testing.value) return
if (!pendingTestModel.value) return
@@ -557,6 +598,7 @@ async function handleStartPendingTest() {
}
selectedTestEndpoint.value = endpoint
pruneSelectedTestKeyIds()
const model = pendingTestModel.value
const modelName = model.global_model_name || model.provider_model_name
const endpointPrefix = `[${formatApiFormat(endpoint.api_format)}] `
@@ -567,6 +609,7 @@ async function handleStartPendingTest() {
apiFormat: endpoint.api_format,
endpointId: endpoint.id,
endpointBaseUrl: endpoint.base_url,
apiKeyIds: selectedTestKeyIds.value,
applyModelMapping: Boolean(mappedTestModelName.value),
mappedModelName: mappedTestModelName.value ?? undefined,
requestHeaders,
@@ -591,6 +634,7 @@ async function testModelConnection(model: Model) {
selectedTestEndpoint.value = selectPreferredModelTestEndpoint(model, activeEndpoints.value)
const requestedModelName = getModelTestRequestedModelName(model)
selectedTestMappedModelName.value = null
selectedTestKeyIds.value = []
testRequestHeadersResetValue.value = buildDefaultModelTestRequestHeaders()
testRequestHeadersDraft.value = testRequestHeadersResetValue.value
testRequestBodyResetValue.value = buildDefaultModelTestRequestBody(
@@ -601,6 +645,49 @@ async function testModelConnection(model: Model) {
testRequestBodyDraft.value = testRequestBodyResetValue.value
modelTest.testResult.value = null
modelTest.dialogOpen.value = true
void ensureModelTestKeysLoaded()
}
function normalizeSelectedTestKeyIds(ids: string[]): string[] {
const allowed = new Set(testKeyOptions.value.map(option => option.value))
const selected = ids
.map(id => id.trim())
.filter(id => id && allowed.has(id))
return [...new Set(selected)]
}
function pruneSelectedTestKeyIds() {
if (selectedTestKeyIds.value.length === 0) return
selectedTestKeyIds.value = normalizeSelectedTestKeyIds(selectedTestKeyIds.value)
}
async function ensureModelTestKeysLoaded() {
if (modelTestKeysLoadedProviderId.value === props.provider.id || loadingModelTestKeys.value) {
return
}
loadingModelTestKeys.value = true
try {
modelTestProviderKeys.value = await getProviderKeys(props.provider.id)
modelTestKeysLoadedProviderId.value = props.provider.id
pruneSelectedTestKeyIds()
} catch (err: unknown) {
showError(parseApiError(err, '加载测试 Key 失败'), '错误')
} finally {
loadingModelTestKeys.value = false
}
}
function formatTestKeyOptionLabel(key: EndpointAPIKey): string {
const name = key.name?.trim()
const masked = key.api_key_masked?.trim()
const authType = key.auth_type?.trim()
const primary = name || masked || key.id
const suffix = [
masked && masked !== primary ? masked : '',
authType || '',
].filter(Boolean)
return suffix.length > 0 ? `${primary} · ${suffix.join(' · ')}` : primary
}
function getModelTestRequestedModelName(model: Model | null): string {
@@ -661,6 +748,17 @@ watch(
() => syncTestRequestBodyModel(),
)
watch(testKeyOptions, () => pruneSelectedTestKeyIds())
watch(
() => props.provider.id,
() => {
modelTestProviderKeys.value = []
modelTestKeysLoadedProviderId.value = null
selectedTestKeyIds.value = []
},
)
// 暴露给父组件
defineExpose({
reload: refresh