feat(provider): 重构模型测试对话框,加固 Vertex AI 传输层

模型测试:
- 将消息输入替换为完整 JSON 请求体编辑器,支持格式化和校验
- 新增端点选择面板,测试前可选择目标端点
- 新增调试检查器,可查看每次尝试的请求/响应头和体
- 结果视图改用 HorizontalRequestTimeline 组件展示请求追踪
- endpoint_checker 返回完整调试数据,通过 candidate extra_data 持久化

Vertex AI:
- 改进上下文检测逻辑,不再仅依赖 provider_type,支持从 base_url 推断
- Service Account 密钥现支持自动拉取模型(使用 auth_config 而非 api_key)
- 移除 Gemini Developer API 回退,API Key 仅走 Express 模式
- 端点表单为 Vertex AI 显示格式特定的默认路径模板
- 密钥格式校验仅在 auth_type/api_formats 变更时执行

其他:
- 禁用 ClaudeCode 提供商类型创建入口
- Dialog 组件新增 closeOnBackdrop 属性
This commit is contained in:
fawney19
2026-03-19 23:52:17 +08:00
parent e4ebd5cca1
commit 6984984c22
31 changed files with 1609 additions and 434 deletions

View File

@@ -194,6 +194,7 @@ export interface TestModelFailoverRequest {
api_format?: string
endpoint_id?: string
message?: string
request_body?: Record<string, unknown>
request_id?: string
concurrency?: number
}
@@ -212,6 +213,11 @@ export interface TestAttemptDetail {
error_message?: string | null
status_code?: number | null
latency_ms?: number | null
request_url?: string | null
request_headers?: Record<string, unknown> | null
request_body?: unknown
response_headers?: Record<string, unknown> | null
response_body?: unknown
}
export interface TestModelFailoverResponse {

View File

@@ -109,6 +109,7 @@ const props = defineProps<{
zIndex?: number // Custom z-index for nested dialogs (default: 60)
noPadding?: boolean // Disable default content padding
persistent?: boolean // Prevent closing on backdrop click
closeOnBackdrop?: boolean // Allow closing on backdrop click (default: true)
}>()
// Emits 定义
@@ -145,7 +146,7 @@ function handleClose() {
// 处理背景点击
function handleBackdropClick() {
if (!props.persistent) {
if (!props.persistent && props.closeOnBackdrop !== false) {
handleClose()
}
}

View File

@@ -15,6 +15,7 @@ export interface StartTestParams {
apiFormat?: string
endpointId?: string
message?: string
requestBody?: Record<string, unknown>
concurrency?: number
onSuccess?: (result: TestModelFailoverResponse) => void
/** Return `true` to indicate the failure has been handled; otherwise the composable sets `testResult`. */
@@ -61,6 +62,16 @@ export function useModelTest(options: UseModelTestOptions) {
}
}
async function refreshTraceSnapshot(reqId: string) {
try {
const trace = await requestTraceApi.getRequestTrace(reqId, { attemptedOnly: false })
if (requestId.value !== reqId) return
testTrace.value = trace
} catch (err: unknown) {
if (isAxiosError(err) && err.response?.status === 404) return
}
}
function stopPolling(opts: { clearState?: boolean } = {}) {
tracePollToken += 1
if (tracePollTimer) {
@@ -128,6 +139,7 @@ export function useModelTest(options: UseModelTestOptions) {
api_format: params.apiFormat,
endpoint_id: params.endpointId,
...(normalizedMessage ? { message: normalizedMessage } : {}),
...(params.requestBody ? { request_body: params.requestBody } : {}),
request_id: reqId,
concurrency: params.concurrency,
}, {
@@ -135,6 +147,9 @@ export function useModelTest(options: UseModelTestOptions) {
})
if (result.success) {
await refreshTraceSnapshot(reqId)
stopPolling({ clearState: false })
testResult.value = result
const successAttempt = result.attempts.find(a => a.status === 'success')
const latency = successAttempt?.latency_ms != null ? ` (${successAttempt.latency_ms}ms)` : ''
const mapped = successAttempt?.effective_model && successAttempt.effective_model !== params.modelName
@@ -142,10 +157,10 @@ export function useModelTest(options: UseModelTestOptions) {
: ''
params.onSuccess?.(result)
showSuccess(`${params.displayLabel}${mapped} 测试成功${latency}`)
resetState()
return
}
await refreshTraceSnapshot(reqId)
stopPolling({ clearState: false })
const handled = params.onFailure?.(result)
if (!handled) {

View File

@@ -105,7 +105,7 @@
<div class="space-y-1.5">
<Label class="text-xs text-muted-foreground">自定义路径</Label>
<Input
:model-value="getEndpointEditState(endpoint.id)?.path ?? (endpoint.custom_path || '')"
:model-value="getDisplayedPath(endpoint)"
:placeholder="getDefaultPath(endpoint.api_format, endpoint.base_url) || '留空使用默认'"
:disabled="isFixedProvider"
@update:model-value="(v) => updateEndpointField(endpoint.id, 'path', v)"
@@ -1276,10 +1276,19 @@ async function preloadDefaultBodyRules(endpoints: ProviderEndpoint[]): Promise<v
// 获取指定 API 格式的默认路径
function getDefaultPath(apiFormat: string, baseUrl?: string): string {
const providerType = (props.provider?.provider_type || '').toLowerCase()
if (providerType === 'vertex_ai') {
if (apiFormat === 'gemini:chat') {
return '/v1/publishers/google/models/{model}:{action}'
}
if (apiFormat === 'claude:chat') {
return '/v1/projects/{project_id}/locations/{region}/publishers/anthropic/models/{model}:{action}'
}
}
const format = apiFormats.value.find(f => f.value === apiFormat)
const defaultPath = format?.default_path || ''
// Codex 端点使用 /responses 而非 /v1/responses
const providerType = (props.provider?.provider_type || '').toLowerCase()
const isCodex = providerType
? providerType === 'codex'
: (!!baseUrl && isCodexUrl(baseUrl))
@@ -1289,6 +1298,13 @@ function getDefaultPath(apiFormat: string, baseUrl?: string): string {
return defaultPath
}
function getDisplayedPath(endpoint: ProviderEndpoint): string {
if (isFixedProvider.value) {
return getDefaultPath(endpoint.api_format, endpoint.base_url)
}
return getEndpointEditState(endpoint.id)?.path ?? (endpoint.custom_path || '')
}
// 判断是否是 Codex OAuth 端点
function isCodexUrl(baseUrl: string): boolean {
const url = baseUrl.replace(/\/+$/, '')

View File

@@ -937,6 +937,7 @@
ref="modelMappingTabRef"
:key="`mapping-${provider.id}`"
:provider="provider"
:endpoints="endpoints"
:provider-keys="providerKeys"
:models="providerModels"
:mapping-preview="providerMappingPreview"

View File

@@ -45,8 +45,11 @@
<SelectItem value="vertex_ai">
Vertex AI
</SelectItem>
<SelectItem value="claude_code">
ClaudeCode
<SelectItem
value="claude_code"
disabled
>
ClaudeCode暂不可用
</SelectItem>
<SelectItem value="codex">
Codex
@@ -432,6 +435,11 @@ watch(() => form.value.provider_type, () => {
// 提交表单
const handleSubmit = async () => {
if (!isEditMode.value && form.value.provider_type === 'claude_code') {
showError('ClaudeCode 提供商类型暂时禁用', '验证失败')
return
}
// 月卡类型必须设置周期开始时间
if (form.value.billing_type === 'monthly_quota' && !form.value.quota_last_reset_at) {
showError('月卡类型必须设置周期开始时间', '验证失败')

View File

@@ -314,13 +314,19 @@
:result="modelTest.testResult.value"
mode="direct"
:selecting-model-name="testingModelName"
:endpoints="activeEndpoints"
:selected-endpoint="selectedTestEndpoint"
:testing="modelTest.testing.value"
:trace="modelTest.testTrace.value"
:request-id="modelTest.requestId.value"
:message-draft="testMessageDraft"
:request-body-draft="testRequestBodyDraft"
:request-body-error="testRequestBodyError"
:start-disabled="!selectedTestEndpoint || !!testRequestBodyError"
@close="handleTestDialogClose"
@back="handleTestDialogBack"
@select-endpoint="handleSelectTestEndpoint"
@start="handleStartMappingTest"
@update:message-draft="testMessageDraft = $event"
@update:request-body-draft="testRequestBodyDraft = $event"
/>
</template>
@@ -338,6 +344,7 @@ import ModelTestDialog from './ModelTestDialog.vue'
import { useToast } from '@/composables/useToast'
import {
type Model,
type ProviderEndpoint,
type ProviderModelAlias,
type ProviderMappingPreviewResponse,
} from '@/api/endpoints'
@@ -345,6 +352,10 @@ 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 {
buildDefaultModelTestRequestBody,
parseModelTestRequestBodyDraft,
} from './model-test-request'
interface MappingItem {
name: string
@@ -372,6 +383,7 @@ interface CombinedMapping {
const props = defineProps<{
provider: ProviderWithEndpointsSummary
endpoints?: ProviderEndpoint[]
providerKeys?: EndpointAPIKey[]
models?: Model[]
mappingPreview?: ProviderMappingPreviewResponse | null
@@ -396,7 +408,11 @@ const testingMapping = ref<string | null>(null)
const pendingMappingKey = ref<string | null>(null)
const testingModelName = ref<string | null>(null)
const preselectedModelId = ref<string | null>(null)
const testMessageDraft = ref('')
const selectedTestEndpoint = ref<ProviderEndpoint | null>(null)
const testRequestBodyDraft = ref('')
const activeEndpoints = computed(() => (props.endpoints ?? []).filter(endpoint => endpoint.is_active))
const parsedTestRequestBody = computed(() => parseModelTestRequestBodyDraft(testRequestBodyDraft.value))
const testRequestBodyError = computed(() => parsedTestRequestBody.value.error)
// 使用 props 传入的数据
const models = computed(() => props.models ?? [])
@@ -634,31 +650,60 @@ function handleTestDialogClose() {
pendingMappingKey.value = null
testingModelName.value = null
testingMapping.value = null
selectedTestEndpoint.value = null
testRequestBodyDraft.value = ''
}
function handleTestDialogBack() {
if (modelTest.testing.value) return
modelTest.testResult.value = null
modelTest.stopPolling()
}
function handleSelectTestEndpoint(endpointId: string) {
const endpoint = activeEndpoints.value.find(item => item.id === endpointId)
if (!endpoint) return
selectedTestEndpoint.value = endpoint
}
// 测试映射(直连测试,带故障转移和实时进度)
function runMappingTest(testingKey: string, modelName: string) {
if (activeEndpoints.value.length === 0) {
showError('暂无可用于测试的活跃端点')
return
}
pendingMappingKey.value = testingKey
modelTest.testResult.value = null
modelTest.dialogOpen.value = true
testingMapping.value = null
testingModelName.value = modelName
selectedTestEndpoint.value = activeEndpoints.value[0] ?? null
testRequestBodyDraft.value = buildDefaultModelTestRequestBody(modelName)
}
async function handleStartMappingTest() {
if (modelTest.testing.value || !testingModelName.value || !pendingMappingKey.value) return
if (modelTest.testing.value || !testingModelName.value) return
const endpoint = selectedTestEndpoint.value || activeEndpoints.value[0]
if (!endpoint) {
showError('请选择要测试的端点')
return
}
const currentMappingKey = pendingMappingKey.value
testingMapping.value = currentMappingKey
const { value: requestBody, error } = parsedTestRequestBody.value
if (!requestBody || error) {
showError(`测试请求体无效: ${error || '无效 JSON'}`)
return
}
const currentMappingKey = pendingMappingKey.value || testingModelName.value
testingMapping.value = pendingMappingKey.value ? currentMappingKey : null
await modelTest.startTest({
mode: 'direct',
modelName: testingModelName.value,
displayLabel: `映射 "${testingModelName.value}"`,
message: testMessageDraft.value,
onSuccess: () => {
pendingMappingKey.value = null
testingModelName.value = null
},
displayLabel: `[${endpoint.api_format}] 映射 "${testingModelName.value}"`,
apiFormat: endpoint.api_format,
endpointId: endpoint.id,
requestBody,
})
if (pendingMappingKey.value === currentMappingKey) {
pendingMappingKey.value = null

View File

@@ -221,13 +221,14 @@
:testing="modelTest.testing.value"
:trace="modelTest.testTrace.value"
:request-id="modelTest.requestId.value"
:show-endpoint-selector="activeEndpoints.length > 1"
:message-draft="testMessageDraft"
:request-body-draft="testRequestBodyDraft"
:request-body-error="testRequestBodyError"
:start-disabled="!selectedTestEndpoint || !!testRequestBodyError"
@close="handleTestDialogClose"
@back="handleTestDialogBack"
@start="handleStartPendingTest"
@select-endpoint="handleSelectTestEndpoint"
@update:message-draft="testMessageDraft = $event"
@update:request-body-draft="testRequestBodyDraft = $event"
/>
</template>
@@ -250,6 +251,10 @@ import { parseApiError } from '@/utils/errorParser'
import { formatApiFormat } from '@/api/endpoints/types/api-format'
import type { ProviderWithEndpointsSummary } from '@/api/endpoints'
import ModelTestDialog from './ModelTestDialog.vue'
import {
buildDefaultModelTestRequestBody,
parseModelTestRequestBodyDraft,
} from './model-test-request'
const props = defineProps<{
provider: ProviderWithEndpointsSummary
@@ -275,8 +280,10 @@ const localModels = ref<Model[]>([])
const togglingModelId = ref<string | null>(null)
const pendingTestModel = ref<Model | null>(null)
const selectedTestEndpoint = ref<ProviderEndpoint | null>(null)
const testMessageDraft = ref('')
const testRequestBodyDraft = ref('')
const activeEndpoints = computed(() => (props.endpoints ?? []).filter(endpoint => endpoint.is_active))
const parsedTestRequestBody = computed(() => parseModelTestRequestBodyDraft(testRequestBodyDraft.value))
const testRequestBodyError = computed(() => parsedTestRequestBody.value.error)
const models = computed(() => props.models ?? localModels.value)
// 按名称排序的模型列表
const sortedModels = computed(() => {
@@ -442,19 +449,38 @@ function handleTestDialogClose() {
modelTest.resetState()
pendingTestModel.value = null
selectedTestEndpoint.value = null
testRequestBodyDraft.value = ''
}
function handleTestDialogBack() {
if (modelTest.testing.value) return
modelTest.testResult.value = null
selectedTestEndpoint.value = null
modelTest.stopPolling()
}
async function handleSelectTestEndpoint(endpointId: string) {
if (!pendingTestModel.value) return
function handleSelectTestEndpoint(endpointId: string) {
const endpoint = activeEndpoints.value.find(item => item.id === endpointId)
if (!endpoint) return
selectedTestEndpoint.value = endpoint
}
async function handleStartPendingTest() {
if (modelTest.testing.value) return
if (!pendingTestModel.value) return
const endpoint = selectedTestEndpoint.value || activeEndpoints.value[0]
if (!endpoint) {
showError('请选择要测试的端点')
return
}
const { value: requestBody, error } = parsedTestRequestBody.value
if (!requestBody || error) {
showError(`测试请求体无效: ${error || '无效 JSON'}`)
return
}
selectedTestEndpoint.value = endpoint
const model = pendingTestModel.value
const modelName = model.global_model_name || model.provider_model_name
const endpointPrefix = `[${formatApiFormat(endpoint.api_format)}] `
@@ -464,28 +490,16 @@ async function handleSelectTestEndpoint(endpointId: string) {
displayLabel: `${endpointPrefix}${modelName}`,
apiFormat: endpoint.api_format,
endpointId: endpoint.id,
message: testMessageDraft.value,
requestBody,
concurrency: 5,
onSuccess: () => {
pendingTestModel.value = null
selectedTestEndpoint.value = null
},
onError: () => {
if (activeEndpoints.value.length > 1) {
selectedTestEndpoint.value = null
return true
}
},
})
}
async function handleStartPendingTest() {
if (modelTest.testing.value) return
const endpoint = activeEndpoints.value[0]
if (!endpoint) return
await handleSelectTestEndpoint(endpoint.id)
}
async function testModelConnection(model: Model) {
if (modelTest.testing.value) return
@@ -495,7 +509,10 @@ async function testModelConnection(model: Model) {
}
pendingTestModel.value = model
selectedTestEndpoint.value = null
selectedTestEndpoint.value = activeEndpoints.value[0] ?? null
testRequestBodyDraft.value = buildDefaultModelTestRequestBody(
model.global_model_name || model.provider_model_name,
)
modelTest.testResult.value = null
modelTest.dialogOpen.value = true
}

View File

@@ -0,0 +1,47 @@
const DEFAULT_MODEL_TEST_MESSAGE = 'Hello! This is a test message.'
export function buildDefaultModelTestRequestBody(modelName: string): string {
return JSON.stringify({
model: modelName,
messages: [
{
role: 'user',
content: DEFAULT_MODEL_TEST_MESSAGE,
},
],
max_tokens: 30,
temperature: 0.7,
stream: true,
}, null, 2)
}
export function parseModelTestRequestBodyDraft(
draft: string,
): { value: Record<string, unknown> | null; error: string | null } {
const normalized = draft.trim()
if (!normalized) {
return {
value: null,
error: '测试请求体不能为空',
}
}
try {
const parsed = JSON.parse(normalized)
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
return {
value: null,
error: '测试请求体必须是 JSON 对象',
}
}
return {
value: parsed as Record<string, unknown>,
error: null,
}
} catch (error) {
return {
value: null,
error: error instanceof Error ? error.message : '无效的 JSON',
}
}
}

View File

@@ -240,6 +240,10 @@
-->{{ proxyTimingBreakdown(currentAttempt.extra_data.proxy) }}<!--
-->)</span>
</span>
<code
v-if="typeof currentAttempt.extra_data.proxy.node_id === 'string' && currentAttempt.extra_data.proxy.node_id"
class="text-xs font-mono text-muted-foreground"
>节点 Key {{ currentAttempt.extra_data.proxy.node_id }}</code>
</span>
</div>
<div
@@ -462,7 +466,7 @@ interface UsageData {
}
const props = defineProps<{
requestId: string
requestId?: string | null
/** 外部传入的状态码,用于覆盖 trace.final_status 的判断 */
overrideStatusCode?: number
/** 请求侧 API 格式(客户端入口格式) */
@@ -471,6 +475,12 @@ const props = defineProps<{
usageData?: UsageData | null
/** 请求元数据(用于号池调度组装) */
requestMetadata?: Record<string, unknown> | null
/** 已获取的追踪数据;传入时不再内部拉取 */
traceData?: RequestTrace | null
}>()
const emit = defineEmits<{
selectAttempt: [attempt: CandidateRecord | null]
}>()
// 用量数据(从 props 获取)
@@ -525,7 +535,8 @@ const getFinalStatusBadgeVariant = (status: string): BadgeVariant => {
const loading = ref(false)
const error = ref<string | null>(null)
const trace = ref<RequestTrace | null>(null)
const internalTrace = ref<RequestTrace | null>(null)
const trace = computed(() => props.traceData ?? internalTrace.value)
const selectedGroupIndex = ref(0)
const selectedAttemptIndex = ref(0)
const hoveredGroupIndex = ref<number | null>(null)
@@ -1045,6 +1056,10 @@ const currentAttempt = computed(() => {
return selectedGroup.value.allAttempts[selectedAttemptIndex.value] || selectedGroup.value.primary
})
watch(currentAttempt, (attempt) => {
emit('selectAttempt', attempt ?? null)
}, { immediate: true })
const currentGroupTitle = computed(() => {
if (!selectedGroup.value || !currentAttempt.value) return ''
if (selectedGroup.value.isPoolGroup) {
@@ -1224,7 +1239,7 @@ const navigateGroup = (direction: number) => {
// 加载请求追踪数据
const isSilentRefresh = ref(false)
const loadTrace = async (silent = false) => {
if (!props.requestId) return
if (!props.requestId || props.traceData) return
isSilentRefresh.value = silent
@@ -1234,7 +1249,7 @@ const loadTrace = async (silent = false) => {
error.value = null
try {
trace.value = await requestTraceApi.getRequestTrace(props.requestId)
internalTrace.value = await requestTraceApi.getRequestTrace(props.requestId)
} catch (err: unknown) {
if (!silent) {
error.value = parseApiError(err, '加载失败')
@@ -1304,12 +1319,31 @@ watch(groupedTimeline, (newGroups) => {
selectedAttemptIndex.value = 0
}, { immediate: true })
// 监听 requestId 变化
watch(() => props.requestId, () => {
selectedGroupIndex.value = 0
selectedAttemptIndex.value = 0
loadTrace()
}, { immediate: true })
// 监听 requestId / 外部 trace 变化
watch(
[() => props.requestId, () => props.traceData],
() => {
selectedGroupIndex.value = 0
selectedAttemptIndex.value = 0
if (props.traceData) {
internalTrace.value = null
loading.value = false
error.value = null
return
}
if (!props.requestId) {
internalTrace.value = null
loading.value = false
error.value = null
return
}
void loadTrace()
},
{ immediate: true },
)
defineExpose({ refresh: () => loadTrace(true) })