Merge remote-tracking branch 'pr-434/fix-provider-model-test-compat' into aether-rust-pioneer

This commit is contained in:
fawney19
2026-05-14 01:28:33 +08:00
29 changed files with 8478 additions and 2385 deletions

View File

@@ -658,7 +658,9 @@
v-if="hasCodexSparkQuotaDisplayData(key)"
class="mt-3 border-t border-border/60 pt-2"
>
<div class="mb-1 text-[10px] text-muted-foreground">GPT-5.3 Codex Spark</div>
<div class="mb-1 text-[10px] text-muted-foreground">
GPT-5.3 Codex Spark
</div>
<div class="grid gap-3 grid-cols-2">
<div v-if="getCodexQuotaDisplay(key)?.spark_secondary_used_percent !== undefined">
<div class="flex items-center justify-between text-[10px] mb-0.5">
@@ -1078,7 +1080,8 @@
:provider="provider"
:models="providerModels"
:endpoints="endpoints"
:loading="loadingProviderModels"
:provider-keys="providerKeys"
:loading="loadingProviderModels || loadingProviderKeys"
@edit-model="handleEditModel"
@batch-assign="handleBatchAssign"
@refresh="loadEndpoints"
@@ -1291,7 +1294,6 @@ import {
import type {
UpstreamMetadata,
AntigravityModelQuota,
AntigravityUpstreamMetadata,
CodexUpstreamMetadata,
ChatGPTWebUpstreamMetadata,
KiroUpstreamMetadata,

View File

@@ -208,6 +208,7 @@ import {
import { updateModel } from '@/api/endpoints/models'
import { parseApiError, parseTestModelError } from '@/utils/errorParser'
import { formatApiFormat } from '@/api/endpoints/types/api-format'
import { buildExactModelMappingTestRequest } from './model-test-request'
import type { ProviderWithEndpointsSummary } from '@/api/endpoints'
const props = defineProps<{
@@ -428,11 +429,9 @@ async function testMapping(group: AliasGroup, mapping: ProviderModelAlias) {
apiFormat = group.model.effective_api_format || group.model.api_format
}
const result = await testModel({
provider_id: props.provider.id,
model_name: mapping.name, // 使用映射名称进行测试
api_format: apiFormat
})
const result = await testModel(
buildExactModelMappingTestRequest(props.provider.id, mapping.name, apiFormat)
)
if (result.success) {
showSuccess(`映射 "${mapping.name}" 测试成功`)

View File

@@ -321,8 +321,9 @@
:open="modelTest.dialogOpen.value"
:result="modelTest.testResult.value"
mode="direct"
:provider-type="provider.provider_type"
:selecting-model-name="testingModelName"
:endpoints="activeEndpoints"
:endpoints="selectableTestEndpoints"
:selected-endpoint="selectedTestEndpoint"
:testing="modelTest.testing.value"
:trace="modelTest.testTrace.value"
@@ -365,13 +366,14 @@ import { type EndpointAPIKey } from '@/api/endpoints/keys'
import { updateModel } from '@/api/endpoints/models'
import { parseApiError } from '@/utils/errorParser'
import type { ProviderWithEndpointsSummary } from '@/api/endpoints'
import { normalizeApiFormatAlias } from '@/api/endpoints/types/api-format'
import {
buildDefaultModelTestRequestHeaders,
buildDefaultModelTestRequestBody,
isModelTestableEndpoint,
parseModelTestRequestHeadersDraft,
parseModelTestRequestBodyDraft,
POOL_TEST_CONCURRENCY,
SINGLE_TEST_CONCURRENCY,
syncModelTestRequestBodyDraft,
} from './model-test-request'
interface MappingItem {
@@ -431,8 +433,11 @@ const testRequestHeadersDraft = ref('')
const testRequestHeadersResetValue = ref('')
const testRequestBodyDraft = ref('')
const testRequestBodyResetValue = ref('')
const isPoolManagedProvider = computed(() => Boolean(props.provider.pool_advanced))
const activeEndpoints = computed(() => (props.endpoints ?? []).filter(endpoint => endpoint.is_active))
const mappingTestEndpoints = ref<ProviderEndpoint[] | null>(null)
const providerKeysState = computed(() => props.providerKeys ?? [])
const activeEndpoints = computed(() => (props.endpoints ?? [])
.filter(endpoint => isModelTestableEndpoint(endpoint, providerKeysState.value)))
const selectableTestEndpoints = computed(() => mappingTestEndpoints.value ?? activeEndpoints.value)
const parsedTestRequestHeaders = computed(() => parseModelTestRequestHeadersDraft(testRequestHeadersDraft.value))
const testRequestHeadersError = computed(() => parsedTestRequestHeaders.value.error)
const parsedTestRequestBody = computed(() => parseModelTestRequestBodyDraft(testRequestBodyDraft.value))
@@ -442,7 +447,6 @@ const isLoading = computed(() => Boolean(props.loading) || localLoading.value)
// 使用 props 传入的数据
const models = computed(() => props.models ?? [])
const aliasMappingPreview = computed(() => props.mappingPreview ?? null)
const providerKeysState = computed(() => props.providerKeys ?? [])
// 是否有 key 配置了自动获取上游模型
const hasAutoFetchKey = computed(() => {
@@ -705,6 +709,7 @@ function handleTestDialogClose() {
testingModelName.value = null
testingMapping.value = null
selectedTestEndpoint.value = null
mappingTestEndpoints.value = null
testRequestHeadersDraft.value = ''
testRequestHeadersResetValue.value = ''
testRequestBodyDraft.value = ''
@@ -718,14 +723,16 @@ function handleTestDialogBack() {
}
function handleSelectTestEndpoint(endpointId: string) {
const endpoint = activeEndpoints.value.find(item => item.id === endpointId)
const endpoint = selectableTestEndpoints.value.find(item => item.id === endpointId)
if (!endpoint) return
selectedTestEndpoint.value = endpoint
syncMappingTestRequestBody()
}
// 测试映射(直连测试,带故障转移和实时进度)
function runMappingTest(testingKey: string, modelName: string) {
if (activeEndpoints.value.length === 0) {
function runMappingTest(testingKey: string, modelName: string, endpointsOverride?: ProviderEndpoint[]) {
const endpoints = endpointsOverride ?? activeEndpoints.value
if (endpoints.length === 0) {
showError('暂无可用于测试的活跃端点')
return
}
@@ -734,16 +741,43 @@ function runMappingTest(testingKey: string, modelName: string) {
modelTest.dialogOpen.value = true
testingMapping.value = null
testingModelName.value = modelName
selectedTestEndpoint.value = activeEndpoints.value[0] ?? null
mappingTestEndpoints.value = endpointsOverride ?? null
selectedTestEndpoint.value = endpoints[0] ?? null
testRequestHeadersResetValue.value = buildDefaultModelTestRequestHeaders()
testRequestHeadersDraft.value = testRequestHeadersResetValue.value
testRequestBodyResetValue.value = buildDefaultModelTestRequestBody(modelName, selectedTestEndpoint.value?.api_format)
resetMappingTestRequestBody()
}
function resetMappingTestRequestBody() {
if (!testingModelName.value) return
testRequestBodyResetValue.value = buildDefaultModelTestRequestBody(
testingModelName.value,
selectedTestEndpoint.value?.api_format,
)
testRequestBodyDraft.value = testRequestBodyResetValue.value
}
function syncMappingTestRequestBody() {
if (!testingModelName.value) return
const nextResetValue = buildDefaultModelTestRequestBody(
testingModelName.value,
selectedTestEndpoint.value?.api_format,
)
const next = syncModelTestRequestBodyDraft(
testRequestBodyDraft.value,
testRequestBodyResetValue.value,
nextResetValue,
testingModelName.value,
)
testRequestBodyResetValue.value = next.resetValue
testRequestBodyDraft.value = next.draft
}
async function handleStartMappingTest() {
if (modelTest.testing.value || !testingModelName.value) return
const endpoint = selectedTestEndpoint.value || activeEndpoints.value[0]
const endpoint = selectedTestEndpoint.value || selectableTestEndpoints.value[0]
if (!endpoint) {
showError('请选择要测试的端点')
return
@@ -772,7 +806,6 @@ async function handleStartMappingTest() {
endpointBaseUrl: endpoint.base_url,
requestHeaders,
requestBody,
concurrency: isPoolManagedProvider.value ? POOL_TEST_CONCURRENCY : SINGLE_TEST_CONCURRENCY,
})
if (pendingMappingKey.value === currentMappingKey) {
pendingMappingKey.value = null
@@ -780,9 +813,25 @@ async function handleStartMappingTest() {
testingMapping.value = null
}
function scopedMappingEndpoints(item: CombinedMapping): ProviderEndpoint[] {
const group = item.group
if (!group) return activeEndpoints.value
const apiFormats = new Set(normalizeStringList(group.apiFormats).map(normalizeApiFormatAlias))
const endpointIds = new Set(normalizeStringList(group.endpointIds))
const matched = activeEndpoints.value.filter((endpoint) => {
const apiFormatMatched = apiFormats.size === 0
|| apiFormats.has(normalizeApiFormatAlias(endpoint.api_format))
const endpointMatched = endpointIds.size === 0 || endpointIds.has(endpoint.id)
return apiFormatMatched && endpointMatched
})
return matched.length > 0 ? matched : activeEndpoints.value
}
// 测试精确映射
function testMapping(item: CombinedMapping, mapping: MappingItem) {
runMappingTest(`${item.key}-${mapping.name}`, mapping.name)
runMappingTest(`${item.key}-${mapping.name}`, mapping.name, scopedMappingEndpoints(item))
}
// 测试正则映射

View File

@@ -209,14 +209,15 @@
请前往"模型目录"页面添加模型
</p>
</div>
</Card>
<ModelTestDialog
:open="modelTest.dialogOpen.value"
:result="modelTest.testResult.value"
:mode="modelTest.testMode.value"
:provider-type="provider.provider_type"
:selecting-model-name="pendingTestModel ? (pendingTestModel.global_model_display_name || pendingTestModel.provider_model_name) : null"
:requested-model-name="pendingRequestedModelName"
:endpoints="activeEndpoints"
:selected-endpoint="selectedTestEndpoint"
:testing="modelTest.testing.value"
@@ -228,18 +229,22 @@
:request-body-draft="testRequestBodyDraft"
:request-body-reset-value="testRequestBodyResetValue"
:request-body-error="testRequestBodyError"
:model-mapping-available="testModelMappingAvailable"
:model-mapping-options="testModelMappingOptions"
:selected-model-mapping="selectedTestMappedModelName"
:start-disabled="!selectedTestEndpoint || !!testRequestHeadersError || !!testRequestBodyError"
@close="handleTestDialogClose"
@back="handleTestDialogBack"
@start="handleStartPendingTest"
@select-endpoint="handleSelectTestEndpoint"
@select-model-mapping="handleSelectModelMapping"
@update:request-headers-draft="testRequestHeadersDraft = $event"
@update:request-body-draft="testRequestBodyDraft = $event"
/>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { ref, computed, watch } from 'vue'
import { useSmartPagination } from '@/composables/useSmartPagination'
import { useModelTest } from '@/composables/useModelTest'
import { Box, Edit, Layers, Power, Copy, Loader2, Play } from 'lucide-vue-next'
@@ -252,6 +257,7 @@ import {
type Model,
type ProviderEndpoint,
} from '@/api/endpoints'
import { 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'
@@ -260,16 +266,19 @@ import ModelTestDialog from './ModelTestDialog.vue'
import {
buildDefaultModelTestRequestHeaders,
buildDefaultModelTestRequestBody,
isModelTestableEndpoint,
listModelTestMappedModelOptions,
normalizeModelTestMappedModelSelection,
parseModelTestRequestHeadersDraft,
parseModelTestRequestBodyDraft,
POOL_TEST_CONCURRENCY,
SINGLE_TEST_CONCURRENCY,
syncModelTestRequestBodyDraft,
} from './model-test-request'
const props = defineProps<{
provider: ProviderWithEndpointsSummary
models?: Model[]
endpoints?: ProviderEndpoint[]
providerKeys?: EndpointAPIKey[]
loading?: boolean
}>()
@@ -295,12 +304,31 @@ const testRequestHeadersDraft = ref('')
const testRequestHeadersResetValue = ref('')
const testRequestBodyDraft = ref('')
const testRequestBodyResetValue = ref('')
const selectedTestMappedModelName = ref<string | null>(null)
const isPoolManagedProvider = computed(() => Boolean(props.provider.pool_advanced))
const activeEndpoints = computed(() => (props.endpoints ?? []).filter(endpoint => endpoint.is_active))
const activeEndpoints = computed(() => (props.endpoints ?? [])
.filter(endpoint => isModelTestableEndpoint(endpoint, props.providerKeys ?? [])))
const parsedTestRequestHeaders = computed(() => parseModelTestRequestHeadersDraft(testRequestHeadersDraft.value))
const testRequestHeadersError = computed(() => parsedTestRequestHeaders.value.error)
const parsedTestRequestBody = computed(() => parseModelTestRequestBodyDraft(testRequestBodyDraft.value))
const testRequestBodyError = computed(() => parsedTestRequestBody.value.error)
const pendingRequestedModelName = computed(() => getModelTestRequestedModelName(pendingTestModel.value))
const testModelMappingOptions = computed(() => {
const requestedModelName = pendingRequestedModelName.value.trim()
return listModelTestMappedModelOptions(pendingTestModel.value, selectedTestEndpoint.value)
.filter(option => option.name !== requestedModelName)
})
const mappedTestModelName = computed(() => {
const selected = selectedTestMappedModelName.value?.trim()
if (!selected) return null
return testModelMappingOptions.value.some(option => option.name === selected)
? selected
: null
})
const testModelMappingAvailable = computed(() => testModelMappingOptions.value.length > 0)
const effectiveTestRequestModelName = computed(() => (
mappedTestModelName.value || pendingRequestedModelName.value
))
const models = computed(() => props.models ?? localModels.value)
const isLoading = computed(() => Boolean(props.loading) || localLoading.value)
// 按名称排序的模型列表
@@ -467,6 +495,7 @@ function handleTestDialogClose() {
modelTest.resetState()
pendingTestModel.value = null
selectedTestEndpoint.value = null
selectedTestMappedModelName.value = null
testRequestHeadersDraft.value = ''
testRequestHeadersResetValue.value = ''
testRequestBodyDraft.value = ''
@@ -483,6 +512,16 @@ function handleSelectTestEndpoint(endpointId: string) {
const endpoint = activeEndpoints.value.find(item => item.id === endpointId)
if (!endpoint) return
selectedTestEndpoint.value = endpoint
syncSelectedTestModelMapping()
resetTestRequestBodyForSelectedEndpoint()
}
function handleSelectModelMapping(modelName: string) {
selectedTestMappedModelName.value = normalizeModelTestMappedModelSelection(
testModelMappingOptions.value,
modelName,
)
syncTestRequestBodyModel()
}
async function handleStartPendingTest() {
@@ -512,15 +551,16 @@ async function handleStartPendingTest() {
const modelName = model.global_model_name || model.provider_model_name
const endpointPrefix = `[${formatApiFormat(endpoint.api_format)}] `
await modelTest.startTest({
mode: 'global',
mode: isPoolManagedProvider.value ? 'pool' : 'global',
modelName,
displayLabel: `${endpointPrefix}${modelName}`,
apiFormat: endpoint.api_format,
endpointId: endpoint.id,
endpointBaseUrl: endpoint.base_url,
applyModelMapping: Boolean(mappedTestModelName.value),
mappedModelName: mappedTestModelName.value ?? undefined,
requestHeaders,
requestBody,
concurrency: isPoolManagedProvider.value ? POOL_TEST_CONCURRENCY : SINGLE_TEST_CONCURRENCY,
onError: () => {
if (activeEndpoints.value.length > 1) {
return true
@@ -539,10 +579,12 @@ async function testModelConnection(model: Model) {
pendingTestModel.value = model
selectedTestEndpoint.value = activeEndpoints.value[0] ?? null
const requestedModelName = getModelTestRequestedModelName(model)
selectedTestMappedModelName.value = null
testRequestHeadersResetValue.value = buildDefaultModelTestRequestHeaders()
testRequestHeadersDraft.value = testRequestHeadersResetValue.value
testRequestBodyResetValue.value = buildDefaultModelTestRequestBody(
model.global_model_name || model.provider_model_name,
requestedModelName,
selectedTestEndpoint.value?.api_format,
)
testRequestBodyDraft.value = testRequestBodyResetValue.value
@@ -550,6 +592,59 @@ async function testModelConnection(model: Model) {
modelTest.dialogOpen.value = true
}
function getModelTestRequestedModelName(model: Model | null): string {
return model?.global_model_name || model?.provider_model_name || ''
}
function syncSelectedTestModelMapping(preferredName?: string | null) {
const options = testModelMappingOptions.value
if (options.length === 0) {
selectedTestMappedModelName.value = null
return
}
const preferred = preferredName ?? selectedTestMappedModelName.value
selectedTestMappedModelName.value = normalizeModelTestMappedModelSelection(options, preferred)
}
function syncTestRequestBodyModel() {
const modelName = effectiveTestRequestModelName.value
if (!modelName) return
const resetDraft = testRequestBodyResetValue.value
|| buildDefaultModelTestRequestBody(modelName, selectedTestEndpoint.value?.api_format)
const next = syncModelTestRequestBodyDraft(
testRequestBodyDraft.value,
testRequestBodyResetValue.value,
resetDraft,
modelName,
)
testRequestBodyResetValue.value = next.resetValue
testRequestBodyDraft.value = next.draft
}
function resetTestRequestBodyForSelectedEndpoint() {
const modelName = effectiveTestRequestModelName.value
if (!modelName) return
const nextResetValue = buildDefaultModelTestRequestBody(
modelName,
selectedTestEndpoint.value?.api_format,
)
const next = syncModelTestRequestBodyDraft(
testRequestBodyDraft.value,
testRequestBodyResetValue.value,
nextResetValue,
modelName,
)
testRequestBodyResetValue.value = next.resetValue
testRequestBodyDraft.value = next.draft
}
watch(
[effectiveTestRequestModelName, () => selectedTestEndpoint.value?.api_format],
() => syncTestRequestBodyModel(),
)
// 暴露给父组件
defineExpose({
reload: refresh

View File

@@ -1,6 +1,17 @@
import { describe, expect, it } from 'vitest'
import { buildDefaultModelTestRequestBody } from '../model-test-request'
import {
buildDefaultModelTestRequestBody,
buildExactModelMappingTestRequest,
extractModelTestResponsePreview,
formatModelTestDiagnostic,
isModelTestableEndpoint,
isModelTestableApiFormat,
listModelTestMappedModelOptions,
normalizeModelTestMappedModelSelection,
setModelTestRequestBodyModel,
syncModelTestRequestBodyDraft,
} from '../model-test-request'
describe('buildDefaultModelTestRequestBody', () => {
it.each([
@@ -28,10 +39,10 @@ describe('buildDefaultModelTestRequestBody', () => {
const body = JSON.parse(buildDefaultModelTestRequestBody('bge-reranker-base', apiFormat))
expect(body.model).toBe('bge-reranker-base')
expect(body.query).toBe('This is a test rerank query.')
expect(body.documents).toHaveLength(2)
expect(body.top_n).toBe(1)
expect(body.query).toBe('Apple')
expect(body.documents).toEqual(['apple', 'banana', 'fruit', 'vegetable'])
expect(body.return_documents).toBe(true)
expect(body.top_n).toBe(4)
expect(body.messages).toBeUndefined()
expect(body.stream).toBeUndefined()
})
@@ -43,4 +54,296 @@ describe('buildDefaultModelTestRequestBody', () => {
expect(body.stream).toBe(true)
expect(body.input).toBeUndefined()
})
it('lists endpoint-scoped provider model mappings in test selection order', () => {
const options = listModelTestMappedModelOptions({
provider_model_name: 'claude-opus-4-6',
provider_model_mappings: [
{
name: 'MiniMax-M2.7-balanced',
priority: 3,
api_formats: ['openai:chat'],
endpoint_ids: ['endpoint-minimax-chat'],
},
{
name: 'MiniMax-M2.7-highspeed',
priority: 2,
api_formats: ['OPENAI'],
endpoint_ids: ['endpoint-minimax-chat'],
},
{
name: 'ignored-anthropic-model',
priority: 1,
api_formats: ['anthropic:messages'],
},
{
name: 'MiniMax-M2.7-highspeed',
priority: 4,
api_formats: ['openai:chat'],
endpoint_ids: ['endpoint-minimax-chat'],
},
],
}, {
id: 'endpoint-minimax-chat',
api_format: 'openai:chat',
})
expect(options).toEqual([
{ name: 'MiniMax-M2.7-highspeed', priority: 2 },
{ name: 'MiniMax-M2.7-balanced', priority: 3 },
])
})
it('does not select a provider model mapping outside endpoint scope', () => {
const options = listModelTestMappedModelOptions({
provider_model_name: 'claude-opus-4-6',
provider_model_mappings: [
{
name: 'MiniMax-M2.7-highspeed',
priority: 1,
api_formats: ['openai:chat'],
endpoint_ids: ['another-endpoint'],
},
],
}, {
id: 'endpoint-minimax-chat',
api_format: 'openai:chat',
})
expect(options).toEqual([])
})
it('keeps the current model selected by default until a mapped model is chosen', () => {
const options = [
{ name: 'MiniMax-M2.7-highspeed', priority: 1 },
{ name: 'MiniMax-M2.7-balanced', priority: 2 },
]
expect(normalizeModelTestMappedModelSelection(options, null)).toBeNull()
expect(normalizeModelTestMappedModelSelection(options, '')).toBeNull()
expect(normalizeModelTestMappedModelSelection(options, 'MiniMax-M2.7-balanced')).toBe('MiniMax-M2.7-balanced')
expect(normalizeModelTestMappedModelSelection(options, 'another-model')).toBeNull()
})
it('updates only the request body model when model mapping is toggled', () => {
const draft = buildDefaultModelTestRequestBody('claude-opus-4-6', 'openai:chat')
const body = JSON.parse(setModelTestRequestBodyModel(draft, 'MiniMax-M2.7-highspeed'))
expect(body.model).toBe('MiniMax-M2.7-highspeed')
expect(body.messages).toEqual([{ role: 'user', content: 'Hello! This is a test message.' }])
expect(body.stream).toBe(true)
})
it('updates the draft to the next endpoint default when the user has not edited it', () => {
const previous = buildDefaultModelTestRequestBody('chat-model', 'openai:chat')
const nextDefault = buildDefaultModelTestRequestBody('chat-model', 'openai:embedding')
const synced = syncModelTestRequestBodyDraft(previous, previous, nextDefault, 'embedding-model')
expect(JSON.parse(synced.draft)).toEqual({
model: 'embedding-model',
input: 'This is a test embedding input.',
})
expect(synced.resetValue).toBe(synced.draft)
})
it('preserves edited request bodies when switching endpoints', () => {
const previous = buildDefaultModelTestRequestBody('chat-model', 'openai:chat')
const edited = JSON.stringify({
model: 'chat-model',
messages: [{ role: 'user', content: 'custom prompt' }],
max_tokens: 128,
temperature: 0.2,
stream: true,
}, null, 2)
const nextDefault = buildDefaultModelTestRequestBody('chat-model', 'openai:embedding')
const synced = syncModelTestRequestBodyDraft(edited, previous, nextDefault, 'embedding-model')
const body = JSON.parse(synced.draft)
expect(body).toEqual({
model: 'embedding-model',
messages: [{ role: 'user', content: 'custom prompt' }],
max_tokens: 128,
temperature: 0.2,
stream: true,
})
expect(JSON.parse(synced.resetValue)).toEqual({
model: 'embedding-model',
input: 'This is a test embedding input.',
})
})
})
describe('buildExactModelMappingTestRequest', () => {
it('tests the clicked mapping name without applying another provider mapping', () => {
expect(buildExactModelMappingTestRequest(
'provider-1',
'MiniMax-M2.7-balanced',
'openai:chat',
)).toEqual({
provider_id: 'provider-1',
model_name: 'MiniMax-M2.7-balanced',
mode: 'direct',
apply_model_mapping: false,
api_format: 'openai:chat',
})
})
})
describe('isModelTestableApiFormat', () => {
it.each([
'openai:video',
'gemini:video',
'gemini:files',
' OPENAI:VIDEO ',
])('excludes task and file endpoint formats from model tests: %s', (apiFormat) => {
expect(isModelTestableApiFormat(apiFormat)).toBe(false)
})
it.each([
'openai:chat',
'openai:responses',
'claude:messages',
'gemini:generate_content',
'openai:embedding',
'jina:rerank',
])('allows synchronous model-test endpoint formats: %s', (apiFormat) => {
expect(isModelTestableApiFormat(apiFormat)).toBe(true)
})
})
describe('isModelTestableEndpoint', () => {
it('requires at least one active key compatible with the endpoint format', () => {
const keys = [
{
api_formats: ['openai:chat'],
is_active: true,
},
{
api_formats: ['claude:messages'],
is_active: false,
},
]
expect(isModelTestableEndpoint({
api_format: 'openai:chat',
is_active: true,
}, keys)).toBe(true)
expect(isModelTestableEndpoint({
api_format: 'claude:messages',
is_active: true,
}, keys)).toBe(false)
})
it('treats an active key without explicit api formats as compatible with all testable endpoints', () => {
const keys = [{ api_formats: [], is_active: true }]
expect(isModelTestableEndpoint({
api_format: 'openai:responses',
is_active: true,
}, keys)).toBe(true)
})
})
describe('formatModelTestDiagnostic', () => {
it('maps pool account blocked scheduler code to an actionable label', () => {
expect(formatModelTestDiagnostic('pool_account_blocked')).toBe('账号已失效,需重新授权')
})
it('keeps unknown diagnostics unchanged', () => {
expect(formatModelTestDiagnostic('provider auth is unavailable')).toBe('provider auth is unavailable')
})
})
describe('extractModelTestResponsePreview', () => {
it('extracts assistant text from Claude Messages response content', () => {
expect(extractModelTestResponsePreview({
id: 'msg_1',
content: [
{
type: 'text',
text: 'Hello! This is a test message.\n\nTest message received.',
},
],
})).toBe('Hello! This is a test message. Test message received.')
})
it('extracts assistant text from OpenAI Responses output content', () => {
expect(extractModelTestResponsePreview({
output: [
{
type: 'message',
content: [
{
type: 'output_text',
text: 'Response API test passed.',
},
],
},
],
})).toBe('Response API test passed.')
})
it('extracts assistant text from Gemini candidates', () => {
expect(extractModelTestResponsePreview({
candidates: [
{
content: {
parts: [
{ text: 'Gemini test passed.' },
],
},
},
],
})).toBe('Gemini test passed.')
})
it('uses OpenAI reasoning content when answer text is empty', () => {
expect(extractModelTestResponsePreview({
model: 'deepseek-v4-pro',
choices: [
{
message: {
role: 'assistant',
content: '',
reasoning_content: 'We are asked: "Hello! This is a test message." This is just a greeting.',
},
finish_reason: 'length',
},
],
})).toBe('推理We are asked: "Hello! This is a test message." This is just a greeting.')
})
it('uses Claude thinking content when no text content is present', () => {
expect(extractModelTestResponsePreview({
model: 'deepseek-v4-pro',
content: [
{
type: 'thinking',
thinking: 'We are given a simple test message: "Hello! This is a test message."',
},
],
stop_reason: 'max_tokens',
})).toBe('推理We are given a simple test message: "Hello! This is a test message."')
})
it('summarizes embedding and rerank responses without assistant text', () => {
expect(extractModelTestResponsePreview({
data: [
{ embedding: [0.1, 0.2, 0.3] },
],
})).toBe('Embedding 维度3')
expect(extractModelTestResponsePreview({
results: [
{ index: 0, relevance_score: 0.9 },
{ index: 1, relevance_score: 0.5 },
],
})).toBe('Rerank 结果2 条')
})
it('falls back to response model when no text payload exists', () => {
expect(extractModelTestResponsePreview({
model: 'glm-4.5-air',
})).toBe('返回模型glm-4.5-air')
})
})

View File

@@ -1,8 +1,412 @@
const DEFAULT_MODEL_TEST_MESSAGE = 'Hello! This is a test message.'
import { normalizeApiFormatAlias } from '@/api/endpoints/types/api-format'
import type { ProviderModelMapping } from '@/api/endpoints/types'
import type { TestModelRequest } from '@/api/endpoints/providers'
/** Pool-managed provider runs concurrent checks; single-key provider does not. */
export const POOL_TEST_CONCURRENCY = 5
export const SINGLE_TEST_CONCURRENCY = 1
const DEFAULT_MODEL_TEST_MESSAGE = 'Hello! This is a test message.'
const MODEL_TEST_RESPONSE_PREVIEW_MAX_LENGTH = 160
type ModelTestMappingSource = {
provider_model_name: string
provider_model_mappings?: ProviderModelMapping[] | null
}
type ModelTestMappingEndpoint = {
id: string
api_format: string
}
type ModelTestEndpointSource = {
api_format: string
is_active?: boolean | null
}
type ModelTestKeySource = {
api_formats?: string[] | null
is_active?: boolean | null
}
export type ModelTestMappedModelOption = {
name: string
priority: number
}
const MODEL_TEST_UNSUPPORTED_API_FORMATS = new Set([
'openai:video',
'gemini:video',
'gemini:files',
])
const MODEL_TEST_DIAGNOSTIC_LABELS: Record<string, string> = {
pool_account_blocked: '账号已失效,需重新授权',
}
type JsonRecord = Record<string, unknown>
export function isModelTestableApiFormat(apiFormat: string | null | undefined): boolean {
const normalized = normalizeApiFormatAlias(apiFormat ?? '')
return Boolean(normalized) && !MODEL_TEST_UNSUPPORTED_API_FORMATS.has(normalized)
}
export function modelTestKeySupportsEndpoint(
key: ModelTestKeySource,
endpoint: ModelTestEndpointSource,
): boolean {
if (key.is_active === false) return false
const endpointFormat = normalizeApiFormatAlias(endpoint.api_format)
if (!isModelTestableApiFormat(endpointFormat)) return false
const keyFormats = normalizeStringList(key.api_formats ?? undefined)
if (keyFormats.length === 0) return true
return keyFormats.some(format => normalizeApiFormatAlias(format) === endpointFormat)
}
export function isModelTestableEndpoint(
endpoint: ModelTestEndpointSource,
keys: ModelTestKeySource[],
): boolean {
return endpoint.is_active !== false
&& isModelTestableApiFormat(endpoint.api_format)
&& keys.some(key => modelTestKeySupportsEndpoint(key, endpoint))
}
export function formatModelTestDiagnostic(value: string | null | undefined): string {
const normalized = value?.trim()
if (!normalized) return ''
return MODEL_TEST_DIAGNOSTIC_LABELS[normalized] ?? normalized
}
export function extractModelTestResponsePreview(responseBody: unknown): string | null {
const text = extractResponseText(responseBody)
if (text) return text
const reasoning = extractResponseReasoning(responseBody)
if (reasoning) return `推理:${reasoning}`
const summary = extractResponseSummary(responseBody)
if (summary) return summary
return null
}
function normalizeStringList(values: string[] | undefined): string[] {
return (values ?? [])
.map(value => value.trim())
.filter(Boolean)
}
function isJsonRecord(value: unknown): value is JsonRecord {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
}
function compactPreviewText(value: unknown): string | null {
if (typeof value !== 'string') return null
const normalized = value.replace(/\s+/g, ' ').trim()
if (!normalized) return null
if (normalized.length <= MODEL_TEST_RESPONSE_PREVIEW_MAX_LENGTH) {
return normalized
}
return `${normalized.slice(0, MODEL_TEST_RESPONSE_PREVIEW_MAX_LENGTH - 3)}...`
}
function joinPreviewParts(parts: string[]): string | null {
return compactPreviewText(parts.filter(Boolean).join(' '))
}
function extractTextFromContentParts(value: unknown, depth = 0): string | null {
if (depth > 4) return null
const directText = compactPreviewText(value)
if (directText) return directText
if (!Array.isArray(value)) return null
const parts = value.flatMap((part) => {
if (typeof part === 'string') return [part]
if (!isJsonRecord(part)) return []
const text = compactPreviewText(part.text)
?? compactPreviewText(part.content)
?? extractTextFromContentParts(part.parts, depth + 1)
return text ? [text] : []
})
return joinPreviewParts(parts)
}
function extractResponseText(responseBody: unknown, depth = 0): string | null {
if (depth > 4 || !isJsonRecord(responseBody)) return null
const wrappedText = extractResponseText(responseBody.response, depth + 1)
?? extractResponseText(responseBody.body, depth + 1)
if (wrappedText) return wrappedText
const outputText = compactPreviewText(responseBody.output_text)
if (outputText) return outputText
const topLevelContentText = extractTextFromContentParts(responseBody.content, depth + 1)
if (topLevelContentText) return topLevelContentText
const choicesText = extractChoicesText(responseBody.choices, depth + 1)
if (choicesText) return choicesText
const outputTextParts = extractOutputText(responseBody.output, depth + 1)
if (outputTextParts) return outputTextParts
const candidateText = extractGeminiCandidateText(responseBody.candidates, depth + 1)
if (candidateText) return candidateText
return null
}
function extractResponseReasoning(responseBody: unknown, depth = 0): string | null {
if (depth > 4 || !isJsonRecord(responseBody)) return null
const wrappedReasoning = extractResponseReasoning(responseBody.response, depth + 1)
?? extractResponseReasoning(responseBody.body, depth + 1)
if (wrappedReasoning) return wrappedReasoning
const directReasoning = compactPreviewText(responseBody.reasoning_content)
?? compactPreviewText(responseBody.thinking)
if (directReasoning) return directReasoning
const topLevelReasoning = extractReasoningFromContentParts(responseBody.content, depth + 1)
if (topLevelReasoning) return topLevelReasoning
const choicesReasoning = extractChoicesReasoning(responseBody.choices, depth + 1)
if (choicesReasoning) return choicesReasoning
const outputReasoning = extractOutputReasoning(responseBody.output, depth + 1)
if (outputReasoning) return outputReasoning
return null
}
function extractChoicesText(value: unknown, depth: number): string | null {
if (!Array.isArray(value)) return null
for (const choice of value) {
if (!isJsonRecord(choice)) continue
const messageText = isJsonRecord(choice.message)
? extractTextFromContentParts(choice.message.content, depth + 1)
: null
const deltaText = isJsonRecord(choice.delta)
? extractTextFromContentParts(choice.delta.content, depth + 1)
: null
const text = messageText ?? deltaText ?? extractTextFromContentParts(choice.text, depth + 1)
if (text) return text
}
return null
}
function extractChoicesReasoning(value: unknown, depth: number): string | null {
if (!Array.isArray(value)) return null
for (const choice of value) {
if (!isJsonRecord(choice)) continue
const messageReasoning = isJsonRecord(choice.message)
? extractReasoningFromMessage(choice.message, depth + 1)
: null
const deltaReasoning = isJsonRecord(choice.delta)
? extractReasoningFromMessage(choice.delta, depth + 1)
: null
const reasoning = messageReasoning ?? deltaReasoning
if (reasoning) return reasoning
}
return null
}
function extractReasoningFromMessage(message: JsonRecord, depth: number): string | null {
return compactPreviewText(message.reasoning_content)
?? compactPreviewText(message.thinking)
?? extractReasoningFromContentParts(message.content, depth + 1)
}
function extractOutputText(value: unknown, depth: number): string | null {
if (!Array.isArray(value)) return null
for (const outputItem of value) {
if (!isJsonRecord(outputItem)) continue
const contentText = extractTextFromContentParts(outputItem.content, depth + 1)
?? extractResponseText(outputItem.response, depth + 1)
if (contentText) return contentText
}
return null
}
function extractOutputReasoning(value: unknown, depth: number): string | null {
if (!Array.isArray(value)) return null
for (const outputItem of value) {
if (!isJsonRecord(outputItem)) continue
const reasoning = extractReasoningFromContentParts(outputItem.content, depth + 1)
?? compactPreviewText(outputItem.reasoning_content)
?? compactPreviewText(outputItem.thinking)
?? extractResponseReasoning(outputItem.response, depth + 1)
if (reasoning) return reasoning
}
return null
}
function extractGeminiCandidateText(value: unknown, depth: number): string | null {
if (!Array.isArray(value)) return null
for (const candidate of value) {
if (!isJsonRecord(candidate) || !isJsonRecord(candidate.content)) continue
const text = extractTextFromContentParts(candidate.content.parts, depth + 1)
if (text) return text
}
return null
}
function extractReasoningFromContentParts(value: unknown, depth = 0): string | null {
if (depth > 4 || !Array.isArray(value)) return null
const parts = value.flatMap((part) => {
if (!isJsonRecord(part)) return []
const reasoning = compactPreviewText(part.reasoning_content)
?? compactPreviewText(part.thinking)
?? compactPreviewText(part.reasoning)
?? extractReasoningFromContentParts(part.content, depth + 1)
?? extractReasoningFromContentParts(part.parts, depth + 1)
return reasoning ? [reasoning] : []
})
return joinPreviewParts(parts)
}
function extractResponseSummary(responseBody: unknown): string | null {
if (!isJsonRecord(responseBody)) return null
if (Array.isArray(responseBody.data)) {
const embeddingDimensions = responseBody.data
.map(item => isJsonRecord(item) && Array.isArray(item.embedding) ? item.embedding.length : null)
.find((size): size is number => typeof size === 'number')
if (embeddingDimensions != null) return `Embedding 维度:${embeddingDimensions}`
if (responseBody.data.length > 0) return `返回数据:${responseBody.data.length}`
}
if (Array.isArray(responseBody.results)) return `Rerank 结果:${responseBody.results.length}`
const model = compactPreviewText(responseBody.model)
if (model) return `返回模型:${model}`
return null
}
function mappingApiFormatMatches(mapping: ProviderModelMapping, endpoint: ModelTestMappingEndpoint): boolean {
const apiFormats = normalizeStringList(mapping.api_formats)
if (apiFormats.length === 0) return true
const endpointFormat = normalizeApiFormatAlias(endpoint.api_format)
return apiFormats.some(format => normalizeApiFormatAlias(format) === endpointFormat)
}
function mappingEndpointMatches(mapping: ProviderModelMapping, endpoint: ModelTestMappingEndpoint): boolean {
const endpointIds = normalizeStringList(mapping.endpoint_ids)
if (endpointIds.length === 0) return true
return endpointIds.includes(endpoint.id)
}
export function listModelTestMappedModelOptions(
model: ModelTestMappingSource | null | undefined,
endpoint: ModelTestMappingEndpoint | null | undefined,
): ModelTestMappedModelOption[] {
if (!model || !endpoint || !Array.isArray(model.provider_model_mappings)) return []
const matchedMappings = model.provider_model_mappings
.filter(mapping => mapping.name.trim())
.filter(mapping => mappingApiFormatMatches(mapping, endpoint))
.filter(mapping => mappingEndpointMatches(mapping, endpoint))
.sort((left, right) => {
const leftPriority = Number.isFinite(left.priority) ? left.priority : 1
const rightPriority = Number.isFinite(right.priority) ? right.priority : 1
return leftPriority - rightPriority || left.name.localeCompare(right.name)
})
const seen = new Set<string>()
return matchedMappings.flatMap((mapping) => {
const name = mapping.name.trim()
const dedupeKey = name.toLowerCase()
if (seen.has(dedupeKey)) return []
seen.add(dedupeKey)
return [{
name,
priority: Number.isFinite(mapping.priority) ? mapping.priority : 1,
}]
})
}
export function normalizeModelTestMappedModelSelection(
options: ModelTestMappedModelOption[],
preferredName: string | null | undefined,
): string | null {
const preferred = preferredName?.trim()
if (!preferred) return null
return options.find(option => option.name === preferred)?.name ?? null
}
export function setModelTestRequestBodyModel(draft: string, modelName: string): string {
const parsed = parseModelTestRequestBodyDraft(draft)
if (!parsed.value || parsed.error) return draft
return JSON.stringify({
...parsed.value,
model: modelName,
}, null, 2)
}
export function syncModelTestRequestBodyDraft(
draft: string,
resetValue: string,
nextResetValue: string,
modelName?: string | null,
): { draft: string; resetValue: string } {
const nextReset = modelName?.trim()
? setModelTestRequestBodyModel(nextResetValue, modelName.trim())
: nextResetValue
const draftIsUntouched = !draft || draft === resetValue
if (draftIsUntouched) {
return {
draft: nextReset,
resetValue: nextReset,
}
}
return {
draft: modelName?.trim()
? setModelTestRequestBodyModel(draft, modelName.trim())
: draft,
resetValue: nextReset,
}
}
export function buildExactModelMappingTestRequest(
providerId: string,
modelName: string,
apiFormat: string | null | undefined,
): TestModelRequest {
return {
provider_id: providerId,
model_name: modelName,
mode: 'direct',
apply_model_mapping: false,
api_format: apiFormat || undefined,
}
}
export function buildDefaultModelTestRequestBody(modelName: string, apiFormat?: string | null): string {
if (apiFormat?.trim().toLowerCase().endsWith(':embedding')) {
@@ -15,13 +419,15 @@ export function buildDefaultModelTestRequestBody(modelName: string, apiFormat?:
if (apiFormat?.trim().toLowerCase().endsWith(':rerank')) {
return JSON.stringify({
model: modelName,
query: 'This is a test rerank query.',
query: 'Apple',
documents: [
'This document is relevant to the test query.',
'This document is unrelated.',
'apple',
'banana',
'fruit',
'vegetable',
],
top_n: 1,
return_documents: true,
top_n: 4,
}, null, 2)
}