mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 01:10:23 +08:00
Merge remote-tracking branch 'pr-434/fix-provider-model-test-compat' into aether-rust-pioneer
This commit is contained in:
@@ -190,15 +190,21 @@ export interface TestModelRequest {
|
||||
endpoint_id?: string
|
||||
message?: string
|
||||
api_format?: string
|
||||
mode?: 'global' | 'direct' | 'pool'
|
||||
apply_model_mapping?: boolean
|
||||
mapped_model_name?: string
|
||||
request_headers?: Record<string, unknown>
|
||||
request_body?: Record<string, unknown>
|
||||
request_id?: string
|
||||
concurrency?: number
|
||||
}
|
||||
|
||||
export interface TestModelResponse {
|
||||
success: boolean
|
||||
error?: string
|
||||
attempts?: TestAttemptDetail[]
|
||||
total_candidates?: number
|
||||
total_attempts?: number
|
||||
candidate_summary?: TestCandidateSummary
|
||||
data?: {
|
||||
response?: {
|
||||
status_code?: number
|
||||
@@ -210,6 +216,7 @@ export interface TestModelResponse {
|
||||
provider?: {
|
||||
id: string
|
||||
name: string
|
||||
provider_type?: string
|
||||
}
|
||||
model?: string
|
||||
}
|
||||
@@ -230,16 +237,17 @@ export async function testModel(
|
||||
*/
|
||||
export interface TestModelFailoverRequest {
|
||||
provider_id: string
|
||||
mode: 'global' | 'direct'
|
||||
mode: 'global' | 'direct' | 'pool'
|
||||
model_name: string
|
||||
failover_models?: string[]
|
||||
api_format?: string
|
||||
endpoint_id?: string
|
||||
message?: string
|
||||
apply_model_mapping?: boolean
|
||||
mapped_model_name?: string
|
||||
request_headers?: Record<string, unknown>
|
||||
request_body?: Record<string, unknown>
|
||||
request_id?: string
|
||||
concurrency?: number
|
||||
}
|
||||
|
||||
export interface TestAttemptDetail {
|
||||
@@ -263,13 +271,36 @@ export interface TestAttemptDetail {
|
||||
response_body?: unknown
|
||||
}
|
||||
|
||||
export interface TestCandidateSummary {
|
||||
total_candidates: number
|
||||
attempted: number
|
||||
success: number
|
||||
failed: number
|
||||
skipped: number
|
||||
unused: number
|
||||
pending?: number
|
||||
available?: number
|
||||
completed?: number
|
||||
stop_reason?: 'first_success' | 'exhausted' | 'all_skipped' | 'no_candidate' | 'pending' | string
|
||||
winning_candidate_index?: number | null
|
||||
winning_key_name?: string | null
|
||||
winning_key_id?: string | null
|
||||
winning_auth_type?: string | null
|
||||
winning_effective_model?: string | null
|
||||
winning_endpoint_api_format?: string | null
|
||||
winning_endpoint_base_url?: string | null
|
||||
winning_latency_ms?: number | null
|
||||
winning_status_code?: number | null
|
||||
}
|
||||
|
||||
export interface TestModelFailoverResponse {
|
||||
success: boolean
|
||||
model: string
|
||||
provider: { id: string; name: string }
|
||||
provider: { id: string; name: string; provider_type?: string }
|
||||
attempts: TestAttemptDetail[]
|
||||
total_candidates: number
|
||||
total_attempts: number
|
||||
candidate_summary?: TestCandidateSummary
|
||||
data?: Record<string, unknown> | null
|
||||
error?: string | null
|
||||
}
|
||||
|
||||
@@ -87,7 +87,7 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
sideOffset: 4,
|
||||
align: undefined,
|
||||
alignOffset: undefined,
|
||||
disablePortal: false,
|
||||
disablePortal: undefined,
|
||||
searchable: true,
|
||||
searchThreshold: 8,
|
||||
searchPlaceholder: '输入关键词搜索...',
|
||||
@@ -95,7 +95,7 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
|
||||
const isInsideDialog = inject(DIALOG_CONTEXT_KEY, false)
|
||||
const shouldDisablePortal = computed(
|
||||
() => props.disablePortal || isInsideDialog,
|
||||
() => props.disablePortal ?? isInsideDialog,
|
||||
)
|
||||
const searchQuery = ref('')
|
||||
const searchInputRef = ref<InstanceType<typeof Input> | null>(null)
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useToast } from './useToast'
|
||||
import {
|
||||
testModel,
|
||||
testModelFailover,
|
||||
type TestCandidateSummary,
|
||||
type TestAttemptDetail,
|
||||
type TestModelResponse,
|
||||
type TestModelFailoverResponse,
|
||||
@@ -12,16 +13,17 @@ import { requestTraceApi, type RequestTrace } from '@/api/requestTrace'
|
||||
import { parseApiError } from '@/utils/errorParser'
|
||||
|
||||
export interface StartTestParams {
|
||||
mode: 'global' | 'direct'
|
||||
mode: 'global' | 'direct' | 'pool'
|
||||
modelName: string
|
||||
displayLabel: string
|
||||
apiFormat?: string
|
||||
endpointId?: string
|
||||
endpointBaseUrl?: string
|
||||
message?: string
|
||||
applyModelMapping?: boolean
|
||||
mappedModelName?: string
|
||||
requestHeaders?: Record<string, unknown>
|
||||
requestBody?: Record<string, unknown>
|
||||
concurrency?: number
|
||||
onSuccess?: (result: TestModelFailoverResponse) => void
|
||||
/** Return `true` to indicate the failure has been handled; otherwise the composable sets `testResult`. */
|
||||
onFailure?: (result: TestModelFailoverResponse) => boolean | void
|
||||
@@ -40,7 +42,7 @@ export function useModelTest(options: UseModelTestOptions) {
|
||||
const LOCAL_FAILOVER_UNCONFIGURED_MESSAGE = 'Rust local provider-query failover simulation is not configured'
|
||||
|
||||
const testing = ref(false)
|
||||
const testMode = ref<'global' | 'direct'>('global')
|
||||
const testMode = ref<'global' | 'direct' | 'pool'>('global')
|
||||
const testResult = ref<TestModelFailoverResponse | null>(null)
|
||||
const testTrace = ref<RequestTrace | null>(null)
|
||||
const requestId = ref<string | null>(null)
|
||||
@@ -79,7 +81,7 @@ export function useModelTest(options: UseModelTestOptions) {
|
||||
: responsePayload?.error?.message
|
||||
) || null
|
||||
const syntheticAttempt: TestAttemptDetail = {
|
||||
candidate_index: 1,
|
||||
candidate_index: 0,
|
||||
endpoint_api_format: params.apiFormat || '-',
|
||||
endpoint_base_url: params.endpointBaseUrl || '',
|
||||
key_name: null,
|
||||
@@ -99,14 +101,45 @@ export function useModelTest(options: UseModelTestOptions) {
|
||||
?? (result.data as Record<string, unknown> | undefined)
|
||||
?? null,
|
||||
}
|
||||
const attempts = Array.isArray(result.attempts) && result.attempts.length > 0
|
||||
? result.attempts
|
||||
: [syntheticAttempt]
|
||||
const totalCandidates = typeof result.total_candidates === 'number'
|
||||
? result.total_candidates
|
||||
: attempts.length
|
||||
const totalAttempts = typeof result.total_attempts === 'number'
|
||||
? result.total_attempts
|
||||
: attempts.filter(attempt => !['skipped', 'available', 'unused'].includes(attempt.status)).length
|
||||
const syntheticSummary: TestCandidateSummary = result.candidate_summary ?? {
|
||||
total_candidates: totalCandidates,
|
||||
attempted: totalAttempts,
|
||||
success: result.success ? 1 : 0,
|
||||
failed: result.success ? 0 : 1,
|
||||
skipped: 0,
|
||||
unused: result.success ? Math.max(0, totalCandidates - totalAttempts) : 0,
|
||||
pending: 0,
|
||||
available: 0,
|
||||
completed: result.success ? totalCandidates : totalAttempts,
|
||||
stop_reason: result.success ? 'first_success' : 'exhausted',
|
||||
winning_candidate_index: result.success ? 0 : null,
|
||||
winning_key_name: null,
|
||||
winning_key_id: '',
|
||||
winning_auth_type: '',
|
||||
winning_effective_model: result.success ? (result.model || params.modelName) : null,
|
||||
winning_endpoint_api_format: params.apiFormat || null,
|
||||
winning_endpoint_base_url: params.endpointBaseUrl || null,
|
||||
winning_latency_ms: null,
|
||||
winning_status_code: responsePayload?.status_code ?? null,
|
||||
}
|
||||
|
||||
return {
|
||||
success: result.success,
|
||||
model: result.model || params.modelName,
|
||||
provider: result.provider || { id: providerId(), name: providerId() },
|
||||
attempts: [syntheticAttempt],
|
||||
total_candidates: 1,
|
||||
total_attempts: 1,
|
||||
attempts,
|
||||
total_candidates: totalCandidates,
|
||||
total_attempts: totalAttempts,
|
||||
candidate_summary: syntheticSummary,
|
||||
data: (result.data as Record<string, unknown> | undefined) ?? null,
|
||||
error: failureMessage,
|
||||
}
|
||||
@@ -120,13 +153,15 @@ export function useModelTest(options: UseModelTestOptions) {
|
||||
return normalizeDirectTestResult(params, await testModel({
|
||||
provider_id: providerId(),
|
||||
model_name: params.modelName,
|
||||
mode: params.mode,
|
||||
api_format: params.apiFormat,
|
||||
endpoint_id: params.endpointId,
|
||||
...(normalizedMessage(params.message) ? { message: normalizedMessage(params.message) } : {}),
|
||||
...(typeof params.applyModelMapping === 'boolean' ? { apply_model_mapping: params.applyModelMapping } : {}),
|
||||
...(params.mappedModelName ? { mapped_model_name: params.mappedModelName } : {}),
|
||||
...(params.requestHeaders ? { request_headers: params.requestHeaders } : {}),
|
||||
...(params.requestBody ? { request_body: params.requestBody } : {}),
|
||||
request_id: reqId,
|
||||
concurrency: params.concurrency,
|
||||
}, {
|
||||
signal,
|
||||
}))
|
||||
@@ -225,10 +260,11 @@ export function useModelTest(options: UseModelTestOptions) {
|
||||
api_format: params.apiFormat,
|
||||
endpoint_id: params.endpointId,
|
||||
...(message ? { message } : {}),
|
||||
...(typeof params.applyModelMapping === 'boolean' ? { apply_model_mapping: params.applyModelMapping } : {}),
|
||||
...(params.mappedModelName ? { mapped_model_name: params.mappedModelName } : {}),
|
||||
...(params.requestHeaders ? { request_headers: params.requestHeaders } : {}),
|
||||
...(params.requestBody ? { request_body: params.requestBody } : {}),
|
||||
request_id: reqId,
|
||||
concurrency: params.concurrency,
|
||||
}, {
|
||||
signal: abortController.signal,
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}" 测试成功`)
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
// 测试正则映射
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user