mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 01:10:23 +08:00
feat(test): 模型测试支持故障转移,展示每次尝试详情
- 后端新增 test-model-failover 接口,支持 global/direct 两种测试模式 - 利用 FailoverEngine 遍历候选并记录每次尝试的状态、延迟、错误等详情 - 前端 ModelsTab/ModelMappingTab 切换到新接口,移除格式选择下拉菜单 - 新增 TestResultDialog 组件,失败时展示候选尝试详情表格 - 简化组件 props 传递,移除不再需要的 endpoints/mappingPreview 依赖
This commit is contained in:
@@ -129,6 +129,48 @@ export async function testModel(data: TestModelRequest): Promise<TestModelRespon
|
|||||||
return response.data
|
return response.data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 带故障转移的模型测试
|
||||||
|
*/
|
||||||
|
export interface TestModelFailoverRequest {
|
||||||
|
provider_id: string
|
||||||
|
mode: 'global' | 'direct'
|
||||||
|
model_name: string
|
||||||
|
api_format?: string
|
||||||
|
message?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TestAttemptDetail {
|
||||||
|
candidate_index: number
|
||||||
|
endpoint_api_format: string
|
||||||
|
endpoint_base_url: string
|
||||||
|
key_name: string | null
|
||||||
|
key_id: string
|
||||||
|
auth_type: string
|
||||||
|
effective_model?: string | null
|
||||||
|
status: 'success' | 'failed' | 'skipped'
|
||||||
|
skip_reason?: string | null
|
||||||
|
error_message?: string | null
|
||||||
|
status_code?: number | null
|
||||||
|
latency_ms?: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TestModelFailoverResponse {
|
||||||
|
success: boolean
|
||||||
|
model: string
|
||||||
|
provider: { id: string; name: string }
|
||||||
|
attempts: TestAttemptDetail[]
|
||||||
|
total_candidates: number
|
||||||
|
total_attempts: number
|
||||||
|
data?: Record<string, unknown> | null
|
||||||
|
error?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function testModelFailover(data: TestModelFailoverRequest): Promise<TestModelFailoverResponse> {
|
||||||
|
const response = await client.post('/api/admin/provider-query/test-model-failover', data)
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 映射预览相关类型
|
* 映射预览相关类型
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -891,9 +891,7 @@
|
|||||||
v-if="provider"
|
v-if="provider"
|
||||||
:key="`models-${provider.id}`"
|
:key="`models-${provider.id}`"
|
||||||
:provider="provider"
|
:provider="provider"
|
||||||
:endpoints="endpoints"
|
|
||||||
:models="providerModels"
|
:models="providerModels"
|
||||||
:mapping-preview="providerMappingPreview"
|
|
||||||
@edit-model="handleEditModel"
|
@edit-model="handleEditModel"
|
||||||
@batch-assign="handleBatchAssign"
|
@batch-assign="handleBatchAssign"
|
||||||
@refresh="loadEndpoints"
|
@refresh="loadEndpoints"
|
||||||
@@ -908,7 +906,6 @@
|
|||||||
:provider-keys="providerKeys"
|
:provider-keys="providerKeys"
|
||||||
:models="providerModels"
|
:models="providerModels"
|
||||||
:mapping-preview="providerMappingPreview"
|
:mapping-preview="providerMappingPreview"
|
||||||
:endpoints="endpoints"
|
|
||||||
@refresh="handleModelMappingChanged"
|
@refresh="handleModelMappingChanged"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -151,47 +151,14 @@
|
|||||||
<span class="font-mono text-sm truncate">
|
<span class="font-mono text-sm truncate">
|
||||||
{{ mapping.name }}
|
{{ mapping.name }}
|
||||||
</span>
|
</span>
|
||||||
<!-- 测试按钮(支持多格式选择) -->
|
<!-- 测试按钮(直连测试) -->
|
||||||
<DropdownMenu
|
|
||||||
v-if="getItemAvailableFormats(item).length > 1"
|
|
||||||
v-model:open="formatMenuOpen[`${item.key}-${mapping.name}`]"
|
|
||||||
>
|
|
||||||
<DropdownMenuTrigger as-child>
|
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
class="h-7 w-7 shrink-0"
|
class="h-7 w-7 shrink-0"
|
||||||
title="测试映射"
|
title="测试映射"
|
||||||
:disabled="testingMapping === `${item.key}-${mapping.name}`"
|
:disabled="testingMapping === `${item.key}-${mapping.name}`"
|
||||||
>
|
@click="testMapping(item, mapping)"
|
||||||
<Loader2
|
|
||||||
v-if="testingMapping === `${item.key}-${mapping.name}`"
|
|
||||||
class="w-3 h-3 animate-spin"
|
|
||||||
/>
|
|
||||||
<Play
|
|
||||||
v-else
|
|
||||||
class="w-3 h-3"
|
|
||||||
/>
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="end">
|
|
||||||
<DropdownMenuItem
|
|
||||||
v-for="fmt in getItemAvailableFormats(item)"
|
|
||||||
:key="fmt"
|
|
||||||
@select="testMapping(item, mapping, fmt)"
|
|
||||||
>
|
|
||||||
{{ fmt }}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
<Button
|
|
||||||
v-else
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
class="h-7 w-7 shrink-0"
|
|
||||||
title="测试映射"
|
|
||||||
:disabled="testingMapping === `${item.key}-${mapping.name}` || getItemAvailableFormats(item).length === 0"
|
|
||||||
@click="testMapping(item, mapping, getItemAvailableFormats(item)[0])"
|
|
||||||
>
|
>
|
||||||
<Loader2
|
<Loader2
|
||||||
v-if="testingMapping === `${item.key}-${mapping.name}`"
|
v-if="testingMapping === `${item.key}-${mapping.name}`"
|
||||||
@@ -340,6 +307,13 @@
|
|||||||
@confirm="confirmDelete"
|
@confirm="confirmDelete"
|
||||||
@cancel="deleteConfirmOpen = false"
|
@cancel="deleteConfirmOpen = false"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<!-- 测试结果对话框(仅失败时显示) -->
|
||||||
|
<TestResultDialog
|
||||||
|
:result="testResult"
|
||||||
|
mode="direct"
|
||||||
|
@close="testResult = null"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
@@ -348,21 +322,21 @@ import { useSmartPagination } from '@/composables/useSmartPagination'
|
|||||||
import { Tag, Plus, Edit, Trash2, ChevronRight, Loader2, Play } from 'lucide-vue-next'
|
import { Tag, Plus, Edit, Trash2, ChevronRight, Loader2, Play } from 'lucide-vue-next'
|
||||||
import {
|
import {
|
||||||
Card, Button, Badge,
|
Card, Button, Badge,
|
||||||
DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem
|
|
||||||
} from '@/components/ui'
|
} from '@/components/ui'
|
||||||
import AlertDialog from '@/components/common/AlertDialog.vue'
|
import AlertDialog from '@/components/common/AlertDialog.vue'
|
||||||
import ModelMappingDialog, { type AliasGroup } from '../ModelMappingDialog.vue'
|
import ModelMappingDialog, { type AliasGroup } from '../ModelMappingDialog.vue'
|
||||||
|
import TestResultDialog from './TestResultDialog.vue'
|
||||||
import { useToast } from '@/composables/useToast'
|
import { useToast } from '@/composables/useToast'
|
||||||
import {
|
import {
|
||||||
testModel,
|
testModelFailover,
|
||||||
type Model,
|
type Model,
|
||||||
type ProviderModelAlias,
|
type ProviderModelAlias,
|
||||||
type ProviderMappingPreviewResponse
|
type ProviderMappingPreviewResponse,
|
||||||
|
type TestModelFailoverResponse
|
||||||
} from '@/api/endpoints'
|
} from '@/api/endpoints'
|
||||||
import { type EndpointAPIKey } from '@/api/endpoints/keys'
|
import { type EndpointAPIKey } from '@/api/endpoints/keys'
|
||||||
import type { ProviderEndpoint } from '@/api/endpoints/types'
|
|
||||||
import { updateModel } from '@/api/endpoints/models'
|
import { updateModel } from '@/api/endpoints/models'
|
||||||
import { parseApiError, parseTestModelError } from '@/utils/errorParser'
|
import { parseApiError } from '@/utils/errorParser'
|
||||||
import type { ProviderWithEndpointsSummary } from '@/api/endpoints'
|
import type { ProviderWithEndpointsSummary } from '@/api/endpoints'
|
||||||
|
|
||||||
interface MappingItem {
|
interface MappingItem {
|
||||||
@@ -394,7 +368,6 @@ const props = defineProps<{
|
|||||||
providerKeys?: EndpointAPIKey[]
|
providerKeys?: EndpointAPIKey[]
|
||||||
models?: Model[]
|
models?: Model[]
|
||||||
mappingPreview?: ProviderMappingPreviewResponse | null
|
mappingPreview?: ProviderMappingPreviewResponse | null
|
||||||
endpoints?: ProviderEndpoint[]
|
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -410,15 +383,12 @@ const deleteConfirmOpen = ref(false)
|
|||||||
const editingGroup = ref<AliasGroup | null>(null)
|
const editingGroup = ref<AliasGroup | null>(null)
|
||||||
const deletingGroup = ref<AliasGroup | null>(null)
|
const deletingGroup = ref<AliasGroup | null>(null)
|
||||||
const testingMapping = ref<string | null>(null)
|
const testingMapping = ref<string | null>(null)
|
||||||
|
const testResult = ref<TestModelFailoverResponse | null>(null)
|
||||||
const preselectedModelId = ref<string | null>(null)
|
const preselectedModelId = ref<string | null>(null)
|
||||||
|
|
||||||
// 测试下拉菜单状态
|
|
||||||
const formatMenuOpen = ref<Record<string, boolean>>({})
|
|
||||||
|
|
||||||
// 使用 props 传入的数据
|
// 使用 props 传入的数据
|
||||||
const models = computed(() => props.models ?? [])
|
const models = computed(() => props.models ?? [])
|
||||||
const aliasMappingPreview = computed(() => props.mappingPreview ?? null)
|
const aliasMappingPreview = computed(() => props.mappingPreview ?? null)
|
||||||
const providerEndpoints = computed(() => props.endpoints ?? [])
|
|
||||||
const providerKeysState = computed(() => props.providerKeys ?? [])
|
const providerKeysState = computed(() => props.providerKeys ?? [])
|
||||||
|
|
||||||
// 是否有 key 配置了自动获取上游模型
|
// 是否有 key 配置了自动获取上游模型
|
||||||
@@ -647,74 +617,24 @@ async function onDialogSaved() {
|
|||||||
emit('refresh')
|
emit('refresh')
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取可用的 API 格式(所有端点,去重;测试只关注 Key 是否支持,不依赖端点启用状态)
|
// 测试映射(直连测试,带故障转移)
|
||||||
const availableApiFormats = computed(() => {
|
async function runMappingTest(testingKey: string, modelName: string) {
|
||||||
const formats = new Set(
|
|
||||||
providerEndpoints.value
|
|
||||||
.map(ep => ep.api_format)
|
|
||||||
)
|
|
||||||
return [...formats]
|
|
||||||
})
|
|
||||||
|
|
||||||
// 获取映射项支持的 API 格式
|
|
||||||
// 逻辑:找到支持该映射格式的所有活跃 Key,获取这些 Key 支持的所有格式,与端点格式取交集
|
|
||||||
function getItemAvailableFormats(item: CombinedMapping): string[] {
|
|
||||||
// 精确映射:基于 group.apiFormats 筛选
|
|
||||||
if (item.type === 'exact' && item.group?.apiFormats && item.group.apiFormats.length > 0) {
|
|
||||||
const mappingFormats = item.group.apiFormats
|
|
||||||
|
|
||||||
// 找到所有支持该映射格式的活跃 Key
|
|
||||||
const supportingKeys = providerKeysState.value.filter(key => {
|
|
||||||
if (!key.is_active) return false
|
|
||||||
// Key 的 api_formats 与映射的 apiFormats 有交集
|
|
||||||
return key.api_formats?.some(fmt => mappingFormats.includes(fmt))
|
|
||||||
})
|
|
||||||
|
|
||||||
if (supportingKeys.length === 0) {
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
|
|
||||||
// 收集这些 Key 支持的所有格式
|
|
||||||
const keyFormats = new Set<string>()
|
|
||||||
for (const key of supportingKeys) {
|
|
||||||
for (const fmt of key.api_formats || []) {
|
|
||||||
keyFormats.add(fmt)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 与端点格式取交集
|
|
||||||
return availableApiFormats.value.filter(fmt => keyFormats.has(fmt))
|
|
||||||
}
|
|
||||||
|
|
||||||
// 正则映射或无限制:返回所有有活跃 Key 支持的端点格式
|
|
||||||
const allKeyFormats = new Set<string>()
|
|
||||||
for (const key of providerKeysState.value) {
|
|
||||||
if (!key.is_active) continue
|
|
||||||
for (const fmt of key.api_formats || []) {
|
|
||||||
allKeyFormats.add(fmt)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return availableApiFormats.value.filter(fmt => allKeyFormats.has(fmt))
|
|
||||||
}
|
|
||||||
|
|
||||||
// 测试精确映射(直接发请求或显示下拉菜单选择格式)
|
|
||||||
async function testMapping(item: CombinedMapping, mapping: MappingItem, apiFormat?: string) {
|
|
||||||
const testingKey = `${item.key}-${mapping.name}`
|
|
||||||
testingMapping.value = testingKey
|
testingMapping.value = testingKey
|
||||||
formatMenuOpen.value[testingKey] = false
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await testModel({
|
const result = await testModelFailover({
|
||||||
provider_id: props.provider.id,
|
provider_id: props.provider.id,
|
||||||
model_name: mapping.name,
|
mode: 'direct',
|
||||||
|
model_name: modelName,
|
||||||
message: "hello",
|
message: "hello",
|
||||||
api_format: apiFormat
|
|
||||||
})
|
})
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
showSuccess(`映射 "${mapping.name}" 测试成功`)
|
const successAttempt = result.attempts.find(a => a.status === 'success')
|
||||||
|
const latency = successAttempt?.latency_ms != null ? ` (${successAttempt.latency_ms}ms)` : ''
|
||||||
|
showSuccess(`映射 "${modelName}" 测试成功${latency}`)
|
||||||
} else {
|
} else {
|
||||||
showError(`映射测试失败: ${parseTestModelError(result)}`)
|
testResult.value = result
|
||||||
}
|
}
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
showError(`映射测试失败: ${parseApiError(err, '测试请求失败')}`)
|
showError(`映射测试失败: ${parseApiError(err, '测试请求失败')}`)
|
||||||
@@ -723,29 +643,14 @@ async function testMapping(item: CombinedMapping, mapping: MappingItem, apiForma
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 测试正则映射(指定 Key,直接发请求,因为已经有 Key 信息)
|
// 测试精确映射
|
||||||
async function testRegexMapping(item: CombinedMapping, keyItem: MatchedKeyInfo, match: MappingItem) {
|
function testMapping(item: CombinedMapping, mapping: MappingItem) {
|
||||||
const testingKey = `${item.key}-${keyItem.keyId}-${match.name}`
|
runMappingTest(`${item.key}-${mapping.name}`, mapping.name)
|
||||||
testingMapping.value = testingKey
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await testModel({
|
|
||||||
provider_id: props.provider.id,
|
|
||||||
model_name: match.name,
|
|
||||||
message: "hello",
|
|
||||||
api_key_id: keyItem.keyId
|
|
||||||
})
|
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
showSuccess(`映射 "${match.name}" 测试成功`)
|
|
||||||
} else {
|
|
||||||
showError(`映射测试失败: ${parseTestModelError(result)}`)
|
|
||||||
}
|
|
||||||
} catch (err: unknown) {
|
|
||||||
showError(`映射测试失败: ${parseApiError(err, '测试请求失败')}`)
|
|
||||||
} finally {
|
|
||||||
testingMapping.value = null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 测试正则映射
|
||||||
|
function testRegexMapping(item: CombinedMapping, keyItem: MatchedKeyInfo, match: MappingItem) {
|
||||||
|
runMappingTest(`${item.key}-${keyItem.keyId}-${match.name}`, match.name)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 暴露给父组件
|
// 暴露给父组件
|
||||||
|
|||||||
@@ -124,47 +124,14 @@
|
|||||||
</td>
|
</td>
|
||||||
<td class="align-top px-4 py-3">
|
<td class="align-top px-4 py-3">
|
||||||
<div class="flex justify-end gap-1">
|
<div class="flex justify-end gap-1">
|
||||||
<!-- 测试按钮(支持多格式选择) -->
|
<!-- 测试按钮(模拟外部请求) -->
|
||||||
<DropdownMenu
|
|
||||||
v-if="availableApiFormats.length > 1"
|
|
||||||
v-model:open="formatMenuOpen[model.id]"
|
|
||||||
>
|
|
||||||
<DropdownMenuTrigger as-child>
|
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
class="h-8 w-8"
|
class="h-8 w-8"
|
||||||
title="测试模型"
|
title="测试模型"
|
||||||
:disabled="testingModelId === model.id"
|
:disabled="testingModelId === model.id"
|
||||||
>
|
@click="testModelConnection(model)"
|
||||||
<Loader2
|
|
||||||
v-if="testingModelId === model.id"
|
|
||||||
class="w-3.5 h-3.5 animate-spin"
|
|
||||||
/>
|
|
||||||
<Play
|
|
||||||
v-else
|
|
||||||
class="w-3.5 h-3.5"
|
|
||||||
/>
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="start">
|
|
||||||
<DropdownMenuItem
|
|
||||||
v-for="fmt in availableApiFormats"
|
|
||||||
:key="fmt"
|
|
||||||
@select="testModelConnection(model, fmt)"
|
|
||||||
>
|
|
||||||
{{ fmt }}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
<Button
|
|
||||||
v-else
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
class="h-8 w-8"
|
|
||||||
title="测试模型"
|
|
||||||
:disabled="testingModelId === model.id"
|
|
||||||
@click="testModelConnection(model, availableApiFormats[0])"
|
|
||||||
>
|
>
|
||||||
<Loader2
|
<Loader2
|
||||||
v-if="testingModelId === model.id"
|
v-if="testingModelId === model.id"
|
||||||
@@ -243,6 +210,13 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<!-- 测试结果对话框 -->
|
||||||
|
<TestResultDialog
|
||||||
|
:result="testResult"
|
||||||
|
:mode="testResultMode"
|
||||||
|
@close="testResult = null"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
@@ -251,36 +225,22 @@ import { useSmartPagination } from '@/composables/useSmartPagination'
|
|||||||
import { Box, Edit, Layers, Power, Copy, Loader2, Play } from 'lucide-vue-next'
|
import { Box, Edit, Layers, Power, Copy, Loader2, Play } from 'lucide-vue-next'
|
||||||
import Card from '@/components/ui/card.vue'
|
import Card from '@/components/ui/card.vue'
|
||||||
import Button from '@/components/ui/button.vue'
|
import Button from '@/components/ui/button.vue'
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem
|
|
||||||
} from '@/components/ui'
|
|
||||||
import { useToast } from '@/composables/useToast'
|
import { useToast } from '@/composables/useToast'
|
||||||
import { useClipboard } from '@/composables/useClipboard'
|
import { useClipboard } from '@/composables/useClipboard'
|
||||||
import { sortResolutionEntries } from '@/utils/form'
|
import { sortResolutionEntries } from '@/utils/form'
|
||||||
import {
|
import {
|
||||||
testModel,
|
testModelFailover,
|
||||||
type Model,
|
type Model,
|
||||||
type ProviderMappingPreviewResponse
|
type TestModelFailoverResponse,
|
||||||
} from '@/api/endpoints'
|
} from '@/api/endpoints'
|
||||||
import { updateModel } from '@/api/endpoints/models'
|
import { updateModel } from '@/api/endpoints/models'
|
||||||
import { parseApiError, parseTestModelError } from '@/utils/errorParser'
|
import { parseApiError } from '@/utils/errorParser'
|
||||||
import type { ProviderWithEndpointsSummary } from '@/api/endpoints'
|
import type { ProviderWithEndpointsSummary } from '@/api/endpoints'
|
||||||
|
import TestResultDialog from './TestResultDialog.vue'
|
||||||
interface Endpoint {
|
|
||||||
id: string
|
|
||||||
api_format: string
|
|
||||||
is_active: boolean
|
|
||||||
active_keys?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
provider: ProviderWithEndpointsSummary
|
provider: ProviderWithEndpointsSummary
|
||||||
endpoints?: Endpoint[]
|
|
||||||
models?: Model[]
|
models?: Model[]
|
||||||
mappingPreview?: ProviderMappingPreviewResponse | null
|
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -295,23 +255,12 @@ const { copyToClipboard } = useClipboard()
|
|||||||
// 状态
|
// 状态
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const localModels = ref<Model[]>([])
|
const localModels = ref<Model[]>([])
|
||||||
const localMappingPreview = ref<ProviderMappingPreviewResponse | null>(null)
|
|
||||||
const togglingModelId = ref<string | null>(null)
|
const togglingModelId = ref<string | null>(null)
|
||||||
const testingModelId = ref<string | null>(null)
|
const testingModelId = ref<string | null>(null)
|
||||||
const formatMenuOpen = ref<Record<string, boolean>>({})
|
const testResult = ref<TestModelFailoverResponse | null>(null)
|
||||||
|
const testResultMode = ref<'global' | 'direct'>('global')
|
||||||
// 使用 props 传入的数据,或使用本地数据
|
// 使用 props 传入的数据,或使用本地数据
|
||||||
const models = computed(() => props.models ?? localModels.value)
|
const models = computed(() => props.models ?? localModels.value)
|
||||||
const mappingPreview = computed(() => props.mappingPreview ?? localMappingPreview.value)
|
|
||||||
|
|
||||||
// 获取可用的 API 格式(有活跃端点且有活跃 Key)
|
|
||||||
const availableApiFormats = computed(() => {
|
|
||||||
if (!props.endpoints) return []
|
|
||||||
return props.endpoints
|
|
||||||
.filter(ep => ep.is_active && (ep.active_keys ?? 0) > 0)
|
|
||||||
.map(ep => ep.api_format)
|
|
||||||
})
|
|
||||||
|
|
||||||
// 按名称排序的模型列表
|
// 按名称排序的模型列表
|
||||||
const sortedModels = computed(() => {
|
const sortedModels = computed(() => {
|
||||||
return [...models.value].sort((a, b) => {
|
return [...models.value].sort((a, b) => {
|
||||||
@@ -472,63 +421,31 @@ async function toggleModelActive(model: Model) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 查找模型的正则映射信息(返回第一个匹配的活跃 key 和映射名称)
|
// 测试模型连接性(模拟外部请求,带故障转移)
|
||||||
function findRegexMapping(model: Model): { keyId: string; mappedName: string } | null {
|
async function testModelConnection(model: Model) {
|
||||||
if (!mappingPreview.value) return null
|
|
||||||
|
|
||||||
// 在映射预览中查找该模型的全局模型 ID
|
|
||||||
const globalModelId = model.global_model_id
|
|
||||||
if (!globalModelId) return null
|
|
||||||
|
|
||||||
for (const keyInfo of mappingPreview.value.keys) {
|
|
||||||
// 跳过未激活的 key
|
|
||||||
if (!keyInfo.is_active) continue
|
|
||||||
|
|
||||||
for (const gm of keyInfo.matching_global_models) {
|
|
||||||
if (gm.global_model_id === globalModelId && gm.matched_models.length > 0) {
|
|
||||||
// 返回第一个匹配的映射名称
|
|
||||||
return {
|
|
||||||
keyId: keyInfo.key_id,
|
|
||||||
mappedName: gm.matched_models[0].allowed_model
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
// 测试模型连接性
|
|
||||||
async function testModelConnection(model: Model, apiFormat?: string) {
|
|
||||||
if (testingModelId.value) return
|
if (testingModelId.value) return
|
||||||
|
|
||||||
testingModelId.value = model.id
|
testingModelId.value = model.id
|
||||||
formatMenuOpen.value[model.id] = false
|
|
||||||
try {
|
try {
|
||||||
// 检查是否有正则映射,如果有则使用映射名称和指定 key
|
const modelName = model.global_model_name || model.provider_model_name
|
||||||
const regexMapping = findRegexMapping(model)
|
|
||||||
const modelName = regexMapping?.mappedName || model.provider_model_name
|
|
||||||
const apiKeyId = regexMapping?.keyId
|
|
||||||
|
|
||||||
const result = await testModel({
|
const result = await testModelFailover({
|
||||||
provider_id: props.provider.id,
|
provider_id: props.provider.id,
|
||||||
|
mode: 'global',
|
||||||
model_name: modelName,
|
model_name: modelName,
|
||||||
message: "hello",
|
message: "hello",
|
||||||
api_format: apiFormat,
|
|
||||||
api_key_id: apiKeyId
|
|
||||||
})
|
})
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
// 根据响应内容显示不同的成功消息
|
const successAttempt = result.attempts.find(a => a.status === 'success')
|
||||||
if (result.data?.response?.choices?.[0]?.message?.content) {
|
const latency = successAttempt?.latency_ms != null ? ` (${successAttempt.latency_ms}ms)` : ''
|
||||||
const content = result.data.response.choices[0].message.content
|
const mapped = successAttempt?.effective_model && successAttempt.effective_model !== modelName
|
||||||
showSuccess(`测试成功,响应: ${content.substring(0, 100)}${content.length > 100 ? '...' : ''}`)
|
? ` -> ${successAttempt.effective_model}`
|
||||||
} else if (result.data?.content_preview) {
|
: ''
|
||||||
showSuccess(`流式测试成功,预览: ${result.data.content_preview}`)
|
showSuccess(`${modelName}${mapped} 测试成功${latency}`)
|
||||||
} else {
|
} else {
|
||||||
showSuccess(`模型 "${modelName}" 测试成功`)
|
testResultMode.value = 'global'
|
||||||
}
|
testResult.value = result
|
||||||
} else {
|
|
||||||
showError(`模型测试失败: ${parseTestModelError(result)}`)
|
|
||||||
}
|
}
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
showError(`模型测试失败: ${parseApiError(err, '测试请求失败')}`)
|
showError(`模型测试失败: ${parseApiError(err, '测试请求失败')}`)
|
||||||
|
|||||||
@@ -0,0 +1,221 @@
|
|||||||
|
<template>
|
||||||
|
<Dialog
|
||||||
|
:open="!!result"
|
||||||
|
size="2xl"
|
||||||
|
title="模型测试结果"
|
||||||
|
@update:open="(val: boolean) => { if (!val) $emit('close') }"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-if="result"
|
||||||
|
class="space-y-4"
|
||||||
|
>
|
||||||
|
<!-- 总体状态 -->
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Badge :variant="result.success ? 'success' : 'destructive'">
|
||||||
|
{{ result.success ? '成功' : '失败' }}
|
||||||
|
</Badge>
|
||||||
|
<span class="text-sm text-muted-foreground">
|
||||||
|
{{ modeLabel }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-muted-foreground">
|
||||||
|
候选 {{ result.total_candidates }} / 尝试 {{ result.total_attempts }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 模型信息 -->
|
||||||
|
<div class="text-sm space-y-1">
|
||||||
|
<div>
|
||||||
|
<span class="text-muted-foreground">请求模型: </span>
|
||||||
|
<span class="font-medium">{{ result.model }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="successEffectiveModel">
|
||||||
|
<span class="text-muted-foreground">发送模型: </span>
|
||||||
|
<span class="font-medium text-primary">{{ successEffectiveModel }}</span>
|
||||||
|
<span
|
||||||
|
v-if="successEffectiveModel !== result.model"
|
||||||
|
class="text-xs text-muted-foreground ml-1"
|
||||||
|
>(已映射)</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 错误信息 -->
|
||||||
|
<div
|
||||||
|
v-if="result.error && !result.success"
|
||||||
|
class="rounded-md bg-destructive/10 border border-destructive/20 px-3 py-2 text-xs text-destructive"
|
||||||
|
>
|
||||||
|
{{ result.error }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Attempt 详情表 -->
|
||||||
|
<div
|
||||||
|
v-if="result.attempts.length > 0"
|
||||||
|
class="border rounded-md overflow-hidden"
|
||||||
|
>
|
||||||
|
<table class="w-full text-xs">
|
||||||
|
<thead>
|
||||||
|
<tr class="border-b bg-muted/30">
|
||||||
|
<th class="px-3 py-2 text-left font-medium">
|
||||||
|
#
|
||||||
|
</th>
|
||||||
|
<th class="px-3 py-2 text-left font-medium">
|
||||||
|
Key
|
||||||
|
</th>
|
||||||
|
<th class="px-3 py-2 text-left font-medium">
|
||||||
|
格式
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
v-if="hasEffectiveModel"
|
||||||
|
class="px-3 py-2 text-left font-medium"
|
||||||
|
>
|
||||||
|
发送模型
|
||||||
|
</th>
|
||||||
|
<th class="px-3 py-2 text-left font-medium">
|
||||||
|
状态
|
||||||
|
</th>
|
||||||
|
<th class="px-3 py-2 text-right font-medium">
|
||||||
|
延迟
|
||||||
|
</th>
|
||||||
|
<th class="px-3 py-2 text-left font-medium">
|
||||||
|
详情
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr
|
||||||
|
v-for="(attempt, idx) in result.attempts"
|
||||||
|
:key="idx"
|
||||||
|
class="border-b last:border-b-0"
|
||||||
|
:class="attemptRowClass(attempt.status)"
|
||||||
|
>
|
||||||
|
<td class="px-3 py-2 text-muted-foreground">
|
||||||
|
{{ attempt.candidate_index }}
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
class="px-3 py-2 max-w-[160px] truncate"
|
||||||
|
:title="attempt.key_name || attempt.key_id"
|
||||||
|
>
|
||||||
|
{{ attempt.key_name || (attempt.key_id.length > 8 ? attempt.key_id.slice(0, 8) + '...' : attempt.key_id) }}
|
||||||
|
<span class="text-muted-foreground ml-1">({{ attempt.auth_type }})</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-3 py-2">
|
||||||
|
<code class="text-[11px] bg-muted px-1 py-0.5 rounded">
|
||||||
|
{{ attempt.endpoint_api_format }}
|
||||||
|
</code>
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
v-if="hasEffectiveModel"
|
||||||
|
class="px-3 py-2 max-w-[180px] truncate"
|
||||||
|
:title="attempt.effective_model || '-'"
|
||||||
|
>
|
||||||
|
{{ attempt.effective_model || '-' }}
|
||||||
|
</td>
|
||||||
|
<td class="px-3 py-2">
|
||||||
|
<Badge
|
||||||
|
:variant="statusVariant(attempt.status)"
|
||||||
|
class="text-[10px] px-1.5 py-0"
|
||||||
|
>
|
||||||
|
{{ statusLabel(attempt.status) }}
|
||||||
|
</Badge>
|
||||||
|
<span
|
||||||
|
v-if="attempt.status_code"
|
||||||
|
class="text-muted-foreground ml-1"
|
||||||
|
>
|
||||||
|
{{ attempt.status_code }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-3 py-2 text-right text-muted-foreground">
|
||||||
|
{{ attempt.latency_ms != null ? attempt.latency_ms + 'ms' : '-' }}
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
class="px-3 py-2 max-w-[200px] truncate text-muted-foreground"
|
||||||
|
:title="attemptDetail(attempt)"
|
||||||
|
>
|
||||||
|
{{ attemptDetail(attempt) }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 无 attempt -->
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="text-center text-sm text-muted-foreground py-4"
|
||||||
|
>
|
||||||
|
没有可用的候选进行测试
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
@click="$emit('close')"
|
||||||
|
>
|
||||||
|
关闭
|
||||||
|
</Button>
|
||||||
|
</template>
|
||||||
|
</Dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { Dialog, Badge } from '@/components/ui'
|
||||||
|
import Button from '@/components/ui/button.vue'
|
||||||
|
import type { TestModelFailoverResponse, TestAttemptDetail } from '@/api/endpoints/providers'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
result: TestModelFailoverResponse | null
|
||||||
|
mode?: 'global' | 'direct'
|
||||||
|
}>()
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
close: []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const modeLabel = computed(() => {
|
||||||
|
if (props.mode === 'global') return '模拟外部请求'
|
||||||
|
if (props.mode === 'direct') return '直接测试'
|
||||||
|
return ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const successEffectiveModel = computed(() => {
|
||||||
|
if (!props.result) return null
|
||||||
|
const successAttempt = props.result.attempts.find(a => a.status === 'success')
|
||||||
|
return successAttempt?.effective_model || null
|
||||||
|
})
|
||||||
|
|
||||||
|
const hasEffectiveModel = computed(() => {
|
||||||
|
if (!props.result) return false
|
||||||
|
return props.result.attempts.some(a => a.effective_model && a.effective_model !== props.result?.model)
|
||||||
|
})
|
||||||
|
|
||||||
|
function statusVariant(status: string) {
|
||||||
|
if (status === 'success') return 'success' as const
|
||||||
|
if (status === 'failed') return 'destructive' as const
|
||||||
|
return 'secondary' as const
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusLabel(status: string) {
|
||||||
|
if (status === 'success') return '成功'
|
||||||
|
if (status === 'failed') return '失败'
|
||||||
|
if (status === 'skipped') return '跳过'
|
||||||
|
return status
|
||||||
|
}
|
||||||
|
|
||||||
|
function attemptRowClass(status: string) {
|
||||||
|
if (status === 'success') return 'bg-green-500/5'
|
||||||
|
if (status === 'failed') return 'bg-red-500/5'
|
||||||
|
if (status === 'skipped') return 'bg-muted/20'
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function attemptDetail(attempt: TestAttemptDetail): string {
|
||||||
|
if (attempt.skip_reason) return attempt.skip_reason
|
||||||
|
if (attempt.error_message) return attempt.error_message
|
||||||
|
if (attempt.status === 'success') return attempt.endpoint_base_url
|
||||||
|
return '-'
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="markdown-viewer-container">
|
<div class="markdown-viewer-container">
|
||||||
|
<!-- eslint-disable vue/no-v-html -->
|
||||||
<div
|
<div
|
||||||
class="markdown-body"
|
class="markdown-body"
|
||||||
v-html="renderedHtml"
|
v-html="renderedHtml"
|
||||||
/>
|
/>
|
||||||
|
<!-- eslint-enable vue/no-v-html -->
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
from typing import Any
|
import time
|
||||||
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
@@ -39,6 +40,9 @@ from src.services.provider.oauth_token import resolve_oauth_access_token
|
|||||||
from src.services.proxy_node.resolver import resolve_effective_proxy
|
from src.services.proxy_node.resolver import resolve_effective_proxy
|
||||||
from src.utils.auth_utils import get_current_user
|
from src.utils.auth_utils import get_current_user
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from src.services.scheduling.schemas import ProviderCandidate
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/admin/provider-query", tags=["Provider Query"])
|
router = APIRouter(prefix="/api/admin/provider-query", tags=["Provider Query"])
|
||||||
|
|
||||||
|
|
||||||
@@ -194,6 +198,46 @@ class TestModelRequest(BaseModel):
|
|||||||
api_format: str | None = None # 指定使用的API格式,如果不指定则使用端点的默认格式
|
api_format: str | None = None # 指定使用的API格式,如果不指定则使用端点的默认格式
|
||||||
|
|
||||||
|
|
||||||
|
class TestModelFailoverRequest(BaseModel):
|
||||||
|
"""带故障转移的模型测试请求"""
|
||||||
|
|
||||||
|
provider_id: str
|
||||||
|
mode: str # "global" = 模拟外部请求(用全局模型名), "direct" = 直接测试(用provider_model_name)
|
||||||
|
model_name: str # global 模式传 global_model_name, direct 模式传 provider_model_name
|
||||||
|
api_format: str | None = None # 指定 API 格式(endpoint signature)
|
||||||
|
message: str | None = "Hello"
|
||||||
|
|
||||||
|
|
||||||
|
class TestAttemptDetail(BaseModel):
|
||||||
|
"""单次测试尝试的详情"""
|
||||||
|
|
||||||
|
candidate_index: int
|
||||||
|
endpoint_api_format: str
|
||||||
|
endpoint_base_url: str
|
||||||
|
key_name: str | None = None
|
||||||
|
key_id: str
|
||||||
|
auth_type: str
|
||||||
|
effective_model: str | None = None # 实际发送的模型名(映射后)
|
||||||
|
status: str # "success" | "failed" | "skipped"
|
||||||
|
skip_reason: str | None = None
|
||||||
|
error_message: str | None = None
|
||||||
|
status_code: int | None = None
|
||||||
|
latency_ms: int | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class TestModelFailoverResponse(BaseModel):
|
||||||
|
"""带故障转移的模型测试响应"""
|
||||||
|
|
||||||
|
success: bool
|
||||||
|
model: str
|
||||||
|
provider: dict[str, str]
|
||||||
|
attempts: list[TestAttemptDetail]
|
||||||
|
total_candidates: int
|
||||||
|
total_attempts: int
|
||||||
|
data: dict | None = None
|
||||||
|
error: str | None = None
|
||||||
|
|
||||||
|
|
||||||
# ============ API Endpoints ============
|
# ============ API Endpoints ============
|
||||||
|
|
||||||
|
|
||||||
@@ -1011,3 +1055,419 @@ async def test_model(
|
|||||||
else None
|
else None
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 带故障转移的模型测试
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _build_direct_test_candidates(
|
||||||
|
provider: Provider,
|
||||||
|
api_format: str | None = None,
|
||||||
|
) -> list[ProviderCandidate]:
|
||||||
|
"""
|
||||||
|
为直接测试模式构建候选列表。
|
||||||
|
|
||||||
|
遍历 Provider 的活跃 Endpoint 和 Key,不经过 GlobalModel 解析。
|
||||||
|
"""
|
||||||
|
from src.services.scheduling.schemas import ProviderCandidate
|
||||||
|
|
||||||
|
candidates: list[ProviderCandidate] = []
|
||||||
|
for endpoint in provider.endpoints or []:
|
||||||
|
if not getattr(endpoint, "is_active", False):
|
||||||
|
continue
|
||||||
|
ep_format = str(getattr(endpoint, "api_format", "") or "")
|
||||||
|
if not ep_format:
|
||||||
|
continue
|
||||||
|
if api_format and ep_format != api_format:
|
||||||
|
continue
|
||||||
|
|
||||||
|
for key in provider.api_keys or []:
|
||||||
|
if not getattr(key, "is_active", False):
|
||||||
|
continue
|
||||||
|
key_formats = getattr(key, "api_formats", None)
|
||||||
|
if key_formats is not None and ep_format not in key_formats:
|
||||||
|
continue
|
||||||
|
|
||||||
|
candidates.append(
|
||||||
|
ProviderCandidate(
|
||||||
|
provider=provider,
|
||||||
|
endpoint=endpoint,
|
||||||
|
key=key,
|
||||||
|
is_skipped=False,
|
||||||
|
provider_api_format=ep_format,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return candidates
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/test-model-failover")
|
||||||
|
async def test_model_failover(
|
||||||
|
request: TestModelFailoverRequest,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
) -> Any:
|
||||||
|
"""
|
||||||
|
带故障转移的模型测试
|
||||||
|
|
||||||
|
支持两种模式:
|
||||||
|
- global: 模拟外部请求,用全局模型名走候选解析(限定当前 Provider)
|
||||||
|
- direct: 直接测试 provider_model_name,在当前 Provider 内多 Key 故障转移
|
||||||
|
"""
|
||||||
|
from src.services.candidate.failover import FailoverEngine
|
||||||
|
from src.services.candidate.policy import RetryMode, RetryPolicy, SkipPolicy
|
||||||
|
from src.services.task.protocol import AttemptKind, AttemptResult
|
||||||
|
|
||||||
|
# 1. 加载 Provider
|
||||||
|
provider = (
|
||||||
|
db.query(Provider)
|
||||||
|
.options(
|
||||||
|
joinedload(Provider.endpoints),
|
||||||
|
joinedload(Provider.api_keys),
|
||||||
|
joinedload(Provider.models),
|
||||||
|
)
|
||||||
|
.filter(Provider.id == request.provider_id)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if not provider:
|
||||||
|
raise HTTPException(status_code=404, detail="Provider not found")
|
||||||
|
|
||||||
|
if request.mode not in ("global", "direct"):
|
||||||
|
raise HTTPException(status_code=400, detail="mode must be 'global' or 'direct'")
|
||||||
|
|
||||||
|
# 2. 构建候选列表
|
||||||
|
candidates = []
|
||||||
|
gm_obj = None # GlobalModel 对象,global 模式下用于 fallback 映射
|
||||||
|
|
||||||
|
if request.mode == "global":
|
||||||
|
# 模拟外部请求:走 CandidateBuilder 候选解析
|
||||||
|
from src.services.scheduling.candidate_builder import CandidateBuilder
|
||||||
|
from src.services.scheduling.candidate_sorter import CandidateSorter
|
||||||
|
from src.services.scheduling.scheduling_config import SchedulingConfig
|
||||||
|
|
||||||
|
sorter = CandidateSorter(SchedulingConfig())
|
||||||
|
builder = CandidateBuilder(sorter)
|
||||||
|
|
||||||
|
# 确定 client_format
|
||||||
|
client_format = request.api_format
|
||||||
|
if not client_format:
|
||||||
|
# 取第一个活跃端点的格式
|
||||||
|
for ep in provider.endpoints or []:
|
||||||
|
if getattr(ep, "is_active", False):
|
||||||
|
client_format = str(getattr(ep, "api_format", "") or "")
|
||||||
|
if client_format:
|
||||||
|
break
|
||||||
|
if not client_format:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400, detail="No active endpoint found to determine API format"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 从 GlobalModel 提取 model_mappings(正则映射规则,用于 Key.allowed_models 匹配)
|
||||||
|
from src.services.cache.model_cache import ModelCacheService
|
||||||
|
|
||||||
|
model_mappings: list[str] = []
|
||||||
|
gm_obj = None
|
||||||
|
try:
|
||||||
|
gm_obj = await ModelCacheService.get_global_model_by_name(db, request.model_name)
|
||||||
|
if gm_obj and isinstance(gm_obj.config, dict):
|
||||||
|
raw_mappings = gm_obj.config.get("model_mappings", [])
|
||||||
|
if isinstance(raw_mappings, list):
|
||||||
|
model_mappings = raw_mappings
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("[test-model-failover] Failed to get GlobalModel mappings: {}", e)
|
||||||
|
|
||||||
|
try:
|
||||||
|
candidates = await builder._build_candidates(
|
||||||
|
db=db,
|
||||||
|
providers=[provider],
|
||||||
|
client_format=client_format,
|
||||||
|
model_name=request.model_name,
|
||||||
|
model_mappings=model_mappings if model_mappings else None,
|
||||||
|
affinity_key=None,
|
||||||
|
is_stream=False,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("[test-model-failover] CandidateBuilder failed: {}", e)
|
||||||
|
candidates = []
|
||||||
|
else:
|
||||||
|
# 直接测试:简单匹配 Endpoint + Key
|
||||||
|
candidates = _build_direct_test_candidates(
|
||||||
|
provider=provider,
|
||||||
|
api_format=request.api_format,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not candidates:
|
||||||
|
return TestModelFailoverResponse(
|
||||||
|
success=False,
|
||||||
|
model=request.model_name,
|
||||||
|
provider={"id": str(provider.id), "name": provider.name},
|
||||||
|
attempts=[],
|
||||||
|
total_candidates=0,
|
||||||
|
total_attempts=0,
|
||||||
|
error="No available candidates found for this model",
|
||||||
|
).model_dump()
|
||||||
|
|
||||||
|
# 3. 定义 attempt_func
|
||||||
|
attempts: list[TestAttemptDetail] = []
|
||||||
|
p_type = str(getattr(provider, "provider_type", "") or "").lower()
|
||||||
|
|
||||||
|
async def _attempt_func(candidate: Any) -> AttemptResult:
|
||||||
|
start_time = time.monotonic()
|
||||||
|
endpoint = candidate.endpoint
|
||||||
|
key = candidate.key
|
||||||
|
candidate_idx = getattr(candidate, "_utf_candidate_index", 0)
|
||||||
|
|
||||||
|
auth_type = str(getattr(key, "auth_type", "api_key") or "api_key").lower()
|
||||||
|
extra_headers: dict[str, str] = {}
|
||||||
|
oauth_meta: dict = {}
|
||||||
|
effective_model = request.model_name
|
||||||
|
attempt_recorded = False
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 解析 Key(复用统一的认证解析逻辑)
|
||||||
|
effective_proxy = resolve_effective_proxy(
|
||||||
|
getattr(provider, "proxy", None), getattr(key, "proxy", None)
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
api_key_value, auth_config = await _resolve_key_auth(
|
||||||
|
key, provider, provider_proxy_config=effective_proxy
|
||||||
|
)
|
||||||
|
except _KeyAuthError as e:
|
||||||
|
raise Exception(e.message) from e
|
||||||
|
oauth_meta = auth_config or {}
|
||||||
|
|
||||||
|
# OAuth 额外头
|
||||||
|
if auth_type == "oauth":
|
||||||
|
account_id = oauth_meta.get("account_id")
|
||||||
|
if account_id:
|
||||||
|
extra_headers["chatgpt-account-id"] = str(account_id)
|
||||||
|
|
||||||
|
ep_extra = get_extra_headers_from_endpoint(endpoint) or {}
|
||||||
|
extra_headers.update(ep_extra)
|
||||||
|
|
||||||
|
# 确定实际模型名
|
||||||
|
effective_model = request.model_name
|
||||||
|
if request.mode == "global":
|
||||||
|
if candidate.mapping_matched_model:
|
||||||
|
effective_model = candidate.mapping_matched_model
|
||||||
|
elif gm_obj:
|
||||||
|
# Fallback: 从 Provider.Model.provider_model_mappings 获取映射
|
||||||
|
# 与正常请求流程中 _get_mapped_model() 的逻辑一致
|
||||||
|
gm_id_str = str(gm_obj.id)
|
||||||
|
for m in provider.models or []:
|
||||||
|
if not getattr(m, "is_active", False):
|
||||||
|
continue
|
||||||
|
if str(getattr(m, "global_model_id", "")) != gm_id_str:
|
||||||
|
continue
|
||||||
|
ep_format = str(getattr(endpoint, "api_format", "") or "")
|
||||||
|
effective_model = m.select_provider_model_name(
|
||||||
|
affinity_key=None, api_format=ep_format
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
"[test-failover] Fallback mapping: {} -> {} "
|
||||||
|
"(provider_model_name={}, has_provider_model_mappings={})",
|
||||||
|
request.model_name,
|
||||||
|
effective_model,
|
||||||
|
m.provider_model_name,
|
||||||
|
bool(m.provider_model_mappings),
|
||||||
|
)
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
logger.info(
|
||||||
|
"[test-failover] No matching Model found for gm_id={} in provider={}",
|
||||||
|
gm_id_str,
|
||||||
|
provider.name,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 获取 adapter
|
||||||
|
adapter_class = get_adapter_for_format(endpoint.api_format)
|
||||||
|
if not adapter_class:
|
||||||
|
raise Exception(f"Unknown API format: {endpoint.api_format}")
|
||||||
|
|
||||||
|
# 构建测试请求
|
||||||
|
check_request = {
|
||||||
|
"model": effective_model,
|
||||||
|
"messages": [{"role": "user", "content": request.message or "Hello"}],
|
||||||
|
"max_tokens": 30,
|
||||||
|
"temperature": 0.7,
|
||||||
|
"stream": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
body_rules = getattr(endpoint, "body_rules", None)
|
||||||
|
header_rules = getattr(endpoint, "header_rules", None)
|
||||||
|
|
||||||
|
# 执行检查
|
||||||
|
response = await adapter_class.check_endpoint(
|
||||||
|
None,
|
||||||
|
endpoint.base_url,
|
||||||
|
api_key_value,
|
||||||
|
check_request,
|
||||||
|
extra_headers if extra_headers else None,
|
||||||
|
body_rules=body_rules,
|
||||||
|
header_rules=header_rules,
|
||||||
|
db=db,
|
||||||
|
user=current_user,
|
||||||
|
provider_name=provider.name,
|
||||||
|
provider_id=str(provider.id),
|
||||||
|
api_key_id=str(key.id),
|
||||||
|
model_name=effective_model,
|
||||||
|
auth_type=auth_type,
|
||||||
|
provider_type=p_type if p_type else None,
|
||||||
|
decrypted_auth_config=oauth_meta if oauth_meta else None,
|
||||||
|
proxy_config=effective_proxy,
|
||||||
|
)
|
||||||
|
|
||||||
|
latency_ms = int((time.monotonic() - start_time) * 1000)
|
||||||
|
status_code = response.get("status_code", 0)
|
||||||
|
|
||||||
|
# 检查响应是否有错误
|
||||||
|
has_error = bool(response.get("error")) or status_code != 200
|
||||||
|
if not has_error:
|
||||||
|
resp_data = response.get("response", {})
|
||||||
|
resp_body = resp_data.get("response_body", {})
|
||||||
|
if isinstance(resp_body, str):
|
||||||
|
try:
|
||||||
|
parsed = json.loads(resp_body)
|
||||||
|
except (json.JSONDecodeError, ValueError):
|
||||||
|
parsed = resp_body
|
||||||
|
else:
|
||||||
|
parsed = resp_body
|
||||||
|
if isinstance(parsed, dict) and "error" in parsed:
|
||||||
|
has_error = True
|
||||||
|
|
||||||
|
if has_error:
|
||||||
|
error_msg = str(response.get("error", ""))[:300]
|
||||||
|
if not error_msg and status_code != 200:
|
||||||
|
error_msg = f"HTTP {status_code}"
|
||||||
|
if not error_msg and isinstance(parsed, dict) and "error" in parsed:
|
||||||
|
err_val = parsed["error"]
|
||||||
|
error_msg = str(
|
||||||
|
err_val.get("message", err_val)
|
||||||
|
if isinstance(err_val, dict)
|
||||||
|
else err_val
|
||||||
|
)[:300]
|
||||||
|
attempts.append(
|
||||||
|
TestAttemptDetail(
|
||||||
|
candidate_index=candidate_idx,
|
||||||
|
endpoint_api_format=str(endpoint.api_format),
|
||||||
|
endpoint_base_url=str(endpoint.base_url)[:80],
|
||||||
|
key_name=getattr(key, "name", None),
|
||||||
|
key_id=str(key.id),
|
||||||
|
auth_type=auth_type,
|
||||||
|
effective_model=effective_model,
|
||||||
|
status="failed",
|
||||||
|
error_message=error_msg,
|
||||||
|
status_code=status_code,
|
||||||
|
latency_ms=latency_ms,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
attempt_recorded = True
|
||||||
|
raise Exception(f"Upstream error: status={status_code}, error={error_msg}")
|
||||||
|
|
||||||
|
# 成功
|
||||||
|
attempts.append(
|
||||||
|
TestAttemptDetail(
|
||||||
|
candidate_index=candidate_idx,
|
||||||
|
endpoint_api_format=str(endpoint.api_format),
|
||||||
|
endpoint_base_url=str(endpoint.base_url)[:80],
|
||||||
|
key_name=getattr(key, "name", None),
|
||||||
|
key_id=str(key.id),
|
||||||
|
auth_type=auth_type,
|
||||||
|
effective_model=effective_model,
|
||||||
|
status="success",
|
||||||
|
status_code=status_code,
|
||||||
|
latency_ms=latency_ms,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return AttemptResult(
|
||||||
|
kind=AttemptKind.SYNC_RESPONSE,
|
||||||
|
http_status=status_code,
|
||||||
|
http_headers={},
|
||||||
|
response_body=response.get("response", response),
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as exc:
|
||||||
|
latency_ms = int((time.monotonic() - start_time) * 1000)
|
||||||
|
# has_error 路径已记录带 status_code 的详细 attempt,此处仅补录早期异常
|
||||||
|
if not attempt_recorded:
|
||||||
|
attempts.append(
|
||||||
|
TestAttemptDetail(
|
||||||
|
candidate_index=candidate_idx,
|
||||||
|
endpoint_api_format=str(endpoint.api_format),
|
||||||
|
endpoint_base_url=str(endpoint.base_url)[:80],
|
||||||
|
key_name=getattr(key, "name", None),
|
||||||
|
key_id=str(key.id),
|
||||||
|
auth_type=auth_type,
|
||||||
|
effective_model=effective_model,
|
||||||
|
status="failed",
|
||||||
|
error_message=str(exc)[:300],
|
||||||
|
latency_ms=latency_ms,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
|
||||||
|
# 4. 预设 candidate index(FailoverEngine 也会 setattr,此处兜底防止 setattr 失败)
|
||||||
|
for i, cand in enumerate(candidates):
|
||||||
|
cand._utf_candidate_index = i # type: ignore[attr-defined]
|
||||||
|
|
||||||
|
# 5. 执行故障转移
|
||||||
|
try:
|
||||||
|
engine = FailoverEngine(db)
|
||||||
|
result = await engine.execute(
|
||||||
|
candidates=candidates,
|
||||||
|
attempt_func=_attempt_func,
|
||||||
|
retry_policy=RetryPolicy(mode=RetryMode.DISABLED),
|
||||||
|
skip_policy=SkipPolicy(),
|
||||||
|
request_id=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 补充 skipped 候选到 attempts
|
||||||
|
for i, cand in enumerate(candidates):
|
||||||
|
if cand.is_skipped and not any(a.candidate_index == i for a in attempts):
|
||||||
|
attempts.append(
|
||||||
|
TestAttemptDetail(
|
||||||
|
candidate_index=i,
|
||||||
|
endpoint_api_format=str(cand.endpoint.api_format),
|
||||||
|
endpoint_base_url=str(cand.endpoint.base_url)[:80],
|
||||||
|
key_name=getattr(cand.key, "name", None),
|
||||||
|
key_id=str(cand.key.id),
|
||||||
|
auth_type=str(getattr(cand.key, "auth_type", "") or ""),
|
||||||
|
status="skipped",
|
||||||
|
skip_reason=cand.skip_reason,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
attempts.sort(key=lambda a: a.candidate_index)
|
||||||
|
|
||||||
|
# 提取成功时的数据
|
||||||
|
data = None
|
||||||
|
if result.success and result.attempt_result:
|
||||||
|
data = {
|
||||||
|
"stream": True,
|
||||||
|
"response": result.attempt_result.response_body,
|
||||||
|
}
|
||||||
|
|
||||||
|
return TestModelFailoverResponse(
|
||||||
|
success=result.success,
|
||||||
|
model=request.model_name,
|
||||||
|
provider={"id": str(provider.id), "name": provider.name},
|
||||||
|
attempts=attempts,
|
||||||
|
total_candidates=len(candidates),
|
||||||
|
total_attempts=result.attempt_count,
|
||||||
|
data=data,
|
||||||
|
error=result.error_message if not result.success else None,
|
||||||
|
).model_dump()
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("[test-model-failover] Error: {}", e)
|
||||||
|
return TestModelFailoverResponse(
|
||||||
|
success=False,
|
||||||
|
model=request.model_name,
|
||||||
|
provider={"id": str(provider.id), "name": provider.name},
|
||||||
|
attempts=attempts,
|
||||||
|
total_candidates=len(candidates),
|
||||||
|
total_attempts=0,
|
||||||
|
error=str(e)[:500],
|
||||||
|
).model_dump()
|
||||||
|
|||||||
Reference in New Issue
Block a user