feat(provider): 模型测试支持自定义请求头,优化对话框布局与并发策略

- 前后端新增 request_headers 字段,测试时可自定义额外请求头
- ModelTestDialog 拆分为请求头/请求体并排双栏布局,增加格式化与重置按钮
- 区分 Pool 托管(并发5)和单 Key Provider(并发1)的测试并发数
- JsonImportInput 新增 multiple prop 支持单文件模式
- KeyFormDialog Service Account 输入改用 JsonImportInput,支持拖拽导入
This commit is contained in:
fawney19
2026-03-20 00:24:56 +08:00
parent 6984984c22
commit 28fa03451c
9 changed files with 295 additions and 58 deletions

View File

@@ -194,6 +194,7 @@ export interface TestModelFailoverRequest {
api_format?: string
endpoint_id?: string
message?: string
request_headers?: Record<string, unknown>
request_body?: Record<string, unknown>
request_id?: string
concurrency?: number

View File

@@ -4,7 +4,7 @@
ref="fileInputRef"
type="file"
:accept="accept"
multiple
:multiple="multiple"
class="hidden"
@change="handleFileSelect"
>
@@ -94,6 +94,7 @@ const props = withDefaults(defineProps<{
disabled?: boolean
resetKey?: string | number
accept?: string
multiple?: boolean
dropTitle?: string
dropHint?: string
manualLabel?: string
@@ -106,6 +107,7 @@ const props = withDefaults(defineProps<{
disabled: false,
resetKey: '',
accept: '.json,.txt',
multiple: true,
dropTitle: '拖入导入文件或点击选择',
dropHint: '支持 .json / .txt可多选',
manualLabel: '',
@@ -218,13 +220,19 @@ function mergeFileContents(contents: string[]): string {
}
async function readFiles(files: File[]) {
const validFiles = files.filter(isValidFileType)
const sourceFiles = props.multiple ? files : files.slice(0, 1)
if (!props.multiple && files.length > 1) {
emitError('仅支持选择 1 个文件,已读取第一个文件', '提示')
}
const validFiles = sourceFiles.filter(isValidFileType)
if (validFiles.length === 0) {
emitError('仅支持 .json 或 .txt 文件', '格式错误')
return
}
if (validFiles.length < files.length) {
emitError(`已忽略 ${files.length - validFiles.length} 个不支持的文件`, '提示')
if (validFiles.length < sourceFiles.length) {
emitError(`已忽略 ${sourceFiles.length - validFiles.length} 个不支持的文件`, '提示')
}
try {

View File

@@ -15,6 +15,7 @@ export interface StartTestParams {
apiFormat?: string
endpointId?: string
message?: string
requestHeaders?: Record<string, unknown>
requestBody?: Record<string, unknown>
concurrency?: number
onSuccess?: (result: TestModelFailoverResponse) => void
@@ -139,6 +140,7 @@ export function useModelTest(options: UseModelTestOptions) {
api_format: params.apiFormat,
endpoint_id: params.endpointId,
...(normalizedMessage ? { message: normalizedMessage } : {}),
...(params.requestHeaders ? { request_headers: params.requestHeaders } : {}),
...(params.requestBody ? { request_body: params.requestBody } : {}),
request_id: reqId,
concurrency: params.concurrency,

View File

@@ -79,18 +79,19 @@
{{ editingKey ? '' : '*' }}
</Label>
<template v-if="form.auth_type === 'service_account'">
<Textarea
:id="apiKeyInputId"
<JsonImportInput
v-model="form.auth_config_text"
:required="!editingKey"
:placeholder="editingKey ? '留空表示不修改' : '粘贴完整的 Service Account JSON'"
class="min-h-[120px] font-mono text-xs"
autocomplete="off"
spellcheck="false"
:disabled="saving"
:reset-key="formNonce"
accept=".json,.txt,application/json,text/plain"
:multiple="false"
drop-title="拖入 Service Account JSON 或点击选择"
drop-hint="支持 .json / .txt单文件导入"
:manual-placeholder="editingKey ? '留空表示不修改或粘贴完整的 Service Account JSON' : '粘贴完整的 Service Account JSON'"
:manual-description="serviceAccountDescription"
textarea-class="min-h-[160px] font-mono text-xs break-all !rounded-xl"
@error="handleServiceAccountImportError"
/>
<p class="text-xs text-muted-foreground mt-1">
JSON 格式包含 project_idprivate_key 等字段
</p>
</template>
<template v-else>
<Input
@@ -330,13 +331,14 @@
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue'
import { Dialog, Button, Input, Label, Switch, Select, SelectTrigger, SelectValue, SelectContent, SelectItem, Textarea } from '@/components/ui'
import { Dialog, Button, Input, Label, Switch, Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '@/components/ui'
import { Key, SquarePen } from 'lucide-vue-next'
import { useToast } from '@/composables/useToast'
import { useFormDialog } from '@/composables/useFormDialog'
import { parseApiError } from '@/utils/errorParser'
import { parseNumberInput, parseNullableNumberInput } from '@/utils/form'
import { log } from '@/utils/logger'
import JsonImportInput from '@/components/common/JsonImportInput.vue'
import {
addProviderKey,
updateProviderKey,
@@ -415,6 +417,12 @@ const visibleApiFormats = computed(() => {
const showAuthTypeSelector = computed(() => props.providerType === 'vertex_ai')
const serviceAccountDescription = computed(() => (
props.editingKey
? '留空表示不修改JSON 格式,包含 project_id、private_key 等字段'
: 'JSON 格式,包含 project_id、private_key 等字段'
))
// 默认认证类型
const defaultAuthType = 'api_key' as const
@@ -676,6 +684,10 @@ function parseAuthConfig(): Record<string, unknown> | null {
}
}
function handleServiceAccountImportError(payload: { message: string, title?: string }) {
showError(payload.message, payload.title || '错误')
}
async function handleSave() {
// 必须有 providerId
if (!props.providerId) {

View File

@@ -319,13 +319,18 @@
:testing="modelTest.testing.value"
:trace="modelTest.testTrace.value"
:request-id="modelTest.requestId.value"
:request-headers-draft="testRequestHeadersDraft"
:request-headers-reset-value="testRequestHeadersResetValue"
:request-headers-error="testRequestHeadersError"
:request-body-draft="testRequestBodyDraft"
:request-body-reset-value="testRequestBodyResetValue"
:request-body-error="testRequestBodyError"
:start-disabled="!selectedTestEndpoint || !!testRequestBodyError"
:start-disabled="!selectedTestEndpoint || !!testRequestHeadersError || !!testRequestBodyError"
@close="handleTestDialogClose"
@back="handleTestDialogBack"
@select-endpoint="handleSelectTestEndpoint"
@start="handleStartMappingTest"
@update:request-headers-draft="testRequestHeadersDraft = $event"
@update:request-body-draft="testRequestBodyDraft = $event"
/>
</template>
@@ -353,8 +358,12 @@ import { updateModel } from '@/api/endpoints/models'
import { parseApiError } from '@/utils/errorParser'
import type { ProviderWithEndpointsSummary } from '@/api/endpoints'
import {
buildDefaultModelTestRequestHeaders,
buildDefaultModelTestRequestBody,
parseModelTestRequestHeadersDraft,
parseModelTestRequestBodyDraft,
POOL_TEST_CONCURRENCY,
SINGLE_TEST_CONCURRENCY,
} from './model-test-request'
interface MappingItem {
@@ -409,8 +418,14 @@ const pendingMappingKey = ref<string | null>(null)
const testingModelName = ref<string | null>(null)
const preselectedModelId = ref<string | null>(null)
const selectedTestEndpoint = ref<ProviderEndpoint | null>(null)
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 parsedTestRequestHeaders = computed(() => parseModelTestRequestHeadersDraft(testRequestHeadersDraft.value))
const testRequestHeadersError = computed(() => parsedTestRequestHeaders.value.error)
const parsedTestRequestBody = computed(() => parseModelTestRequestBodyDraft(testRequestBodyDraft.value))
const testRequestBodyError = computed(() => parsedTestRequestBody.value.error)
@@ -651,7 +666,10 @@ function handleTestDialogClose() {
testingModelName.value = null
testingMapping.value = null
selectedTestEndpoint.value = null
testRequestHeadersDraft.value = ''
testRequestHeadersResetValue.value = ''
testRequestBodyDraft.value = ''
testRequestBodyResetValue.value = ''
}
function handleTestDialogBack() {
@@ -678,7 +696,10 @@ function runMappingTest(testingKey: string, modelName: string) {
testingMapping.value = null
testingModelName.value = modelName
selectedTestEndpoint.value = activeEndpoints.value[0] ?? null
testRequestBodyDraft.value = buildDefaultModelTestRequestBody(modelName)
testRequestHeadersResetValue.value = buildDefaultModelTestRequestHeaders()
testRequestHeadersDraft.value = testRequestHeadersResetValue.value
testRequestBodyResetValue.value = buildDefaultModelTestRequestBody(modelName)
testRequestBodyDraft.value = testRequestBodyResetValue.value
}
async function handleStartMappingTest() {
@@ -689,6 +710,12 @@ async function handleStartMappingTest() {
return
}
const { value: requestHeaders, error: requestHeadersError } = parsedTestRequestHeaders.value
if (!requestHeaders || requestHeadersError) {
showError(`测试请求头无效: ${requestHeadersError || '无效 JSON'}`)
return
}
const { value: requestBody, error } = parsedTestRequestBody.value
if (!requestBody || error) {
showError(`测试请求体无效: ${error || '无效 JSON'}`)
@@ -703,7 +730,9 @@ async function handleStartMappingTest() {
displayLabel: `[${endpoint.api_format}] 映射 "${testingModelName.value}"`,
apiFormat: endpoint.api_format,
endpointId: endpoint.id,
requestHeaders,
requestBody,
concurrency: isPoolManagedProvider.value ? POOL_TEST_CONCURRENCY : SINGLE_TEST_CONCURRENCY,
})
if (pendingMappingKey.value === currentMappingKey) {
pendingMappingKey.value = null

View File

@@ -1,7 +1,7 @@
<template>
<Dialog
:open="open"
size="2xl"
size="3xl"
:close-on-backdrop="false"
@update:open="(val: boolean) => { if (!val) emit('close') }"
>
@@ -37,12 +37,12 @@
当前测试会固定到选中的端点
</div>
</div>
<div class="space-y-2">
<div class="grid gap-2 md:grid-cols-2">
<button
v-for="endpoint in endpoints"
:key="endpoint.id"
type="button"
class="w-full rounded-lg border px-3 py-3 text-left transition-colors"
class="h-full w-full rounded-lg border px-3 py-3 text-left transition-colors"
:class="selectedEndpoint?.id === endpoint.id
? 'border-primary bg-primary/5'
: 'border-border/60 hover:bg-muted/40'"
@@ -65,33 +65,91 @@
</div>
</div>
<div class="space-y-2">
<div class="flex items-center justify-between gap-3">
<div class="text-sm font-medium">
测试请求体
<div class="grid gap-4 lg:grid-cols-2 lg:items-start">
<div class="space-y-2">
<div class="flex items-center justify-between gap-3">
<div class="text-sm font-medium">
测试请求头
</div>
<div class="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
class="h-8 w-8 rounded-lg text-muted-foreground"
title="格式化请求头 JSON"
@click="formatRequestHeadersDraft"
>
<Code2 class="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
class="h-8 w-8 rounded-lg text-muted-foreground"
title="重置请求头"
@click="resetRequestHeadersDraft"
>
<RotateCcw class="h-4 w-4" />
</Button>
</div>
</div>
<Button
variant="ghost"
size="sm"
@click="formatRequestBodyDraft"
<Textarea
:model-value="requestHeadersDraft"
class="min-h-[260px] font-mono text-xs"
placeholder="输入 JSON 请求头"
@update:model-value="emit('update:requestHeadersDraft', $event)"
/>
<div
v-if="requestHeadersError"
class="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-xs text-destructive"
>
格式化 JSON
</Button>
{{ requestHeadersError }}
</div>
<div class="rounded-md border border-border/60 bg-muted/20 px-3 py-2 text-[11px] text-muted-foreground">
这里的请求头会合并到测试请求里鉴权头和必要系统头仍由后端按端点规则补齐
</div>
</div>
<Textarea
:model-value="requestBodyDraft"
class="min-h-[260px] font-mono text-xs"
placeholder="输入 JSON 请求体"
@update:model-value="emit('update:requestBodyDraft', $event)"
/>
<div
v-if="requestBodyError"
class="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-xs text-destructive"
>
{{ requestBodyError }}
</div>
<div class="rounded-md border border-border/60 bg-muted/20 px-3 py-2 text-[11px] text-muted-foreground">
会强制使用当前测试模型这里编辑的是测试基础请求体实际发送时会按端点格式转换并应用规则
<div class="space-y-2">
<div class="flex items-center justify-between gap-3">
<div class="text-sm font-medium">
测试请求体
</div>
<div class="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
class="h-8 w-8 rounded-lg text-muted-foreground"
title="格式化请求体 JSON"
@click="formatRequestBodyDraft"
>
<Code2 class="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
class="h-8 w-8 rounded-lg text-muted-foreground"
title="重置请求体"
@click="resetRequestBodyDraft"
>
<RotateCcw class="h-4 w-4" />
</Button>
</div>
</div>
<Textarea
:model-value="requestBodyDraft"
class="min-h-[260px] font-mono text-xs"
placeholder="输入 JSON 请求体"
@update:model-value="emit('update:requestBodyDraft', $event)"
/>
<div
v-if="requestBodyError"
class="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-xs text-destructive"
>
{{ requestBodyError }}
</div>
<div class="rounded-md border border-border/60 bg-muted/20 px-3 py-2 text-[11px] text-muted-foreground">
会强制使用当前测试模型这里编辑的是测试基础请求体实际发送时会按端点格式转换并应用规则
</div>
</div>
</div>
@@ -582,7 +640,6 @@
<template #footer>
<Button
variant="outline"
size="sm"
@click="emit('close')"
>
{{ showSetup ? '取消' : '关闭' }}
@@ -590,7 +647,6 @@
<Button
v-if="showResult"
variant="outline"
size="sm"
@click="emit('back')"
>
返回
@@ -601,7 +657,7 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { Check, Copy, Loader2, Maximize2, Minimize2 } from 'lucide-vue-next'
import { Check, Code2, Copy, Loader2, Maximize2, Minimize2, RotateCcw } from 'lucide-vue-next'
import {
Badge,
Card,
@@ -635,7 +691,11 @@ const props = defineProps<{
testing?: boolean
trace?: RequestTrace | null
requestId?: string | null
requestHeadersDraft?: string
requestHeadersResetValue?: string
requestHeadersError?: string | null
requestBodyDraft?: string
requestBodyResetValue?: string
requestBodyError?: string | null
startDisabled?: boolean
}>()
@@ -645,10 +705,12 @@ const emit = defineEmits<{
back: []
start: []
selectEndpoint: [endpointId: string]
'update:requestHeadersDraft': [value: string]
'update:requestBodyDraft': [value: string]
}>()
const endpoints = computed(() => props.endpoints ?? [])
const requestHeadersDraft = computed(() => props.requestHeadersDraft ?? '')
const requestBodyDraft = computed(() => props.requestBodyDraft ?? '')
const traceCandidates = computed(() => props.trace?.candidates ?? [])
const showSetup = computed(() => props.open && !props.testing && !props.result)
@@ -664,7 +726,7 @@ const dialogTitle = computed(() => {
const dialogDescription = computed(() => {
if (showSetup.value && props.selectingModelName) {
return `${props.selectingModelName} 选择端点并编辑测试请求体`
return `${props.selectingModelName} 选择端点并编辑测试请求头与请求`
}
if (props.testing && props.selectedEndpoint) {
return `正在通过 ${formatApiFormat(props.selectedEndpoint.api_format)} 测试 ${props.selectingModelName || '模型'}`
@@ -969,11 +1031,39 @@ function collapseInspectionContent() {
inspectionExpandDepth.value = 0
}
function formatRequestHeadersDraft() {
formatJsonDraft(requestHeadersDraft.value, value => emit('update:requestHeadersDraft', value), '{}')
}
function formatRequestBodyDraft() {
formatJsonDraft(requestBodyDraft.value, value => emit('update:requestBodyDraft', value))
}
function resetRequestHeadersDraft() {
emit('update:requestHeadersDraft', props.requestHeadersResetValue ?? '{}')
}
function resetRequestBodyDraft() {
emit('update:requestBodyDraft', props.requestBodyResetValue ?? '')
}
function formatJsonDraft(
draft: string,
onFormatted: (value: string) => void,
emptyFallback?: string,
) {
const normalized = draft.trim()
if (!normalized) {
if (emptyFallback !== undefined) {
onFormatted(emptyFallback)
}
return
}
try {
const parsed = JSON.parse(requestBodyDraft.value)
const parsed = JSON.parse(normalized)
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return
emit('update:requestBodyDraft', JSON.stringify(parsed, null, 2))
onFormatted(JSON.stringify(parsed, null, 2))
} catch {
// keep user input untouched when JSON is invalid
}

View File

@@ -221,13 +221,18 @@
:testing="modelTest.testing.value"
:trace="modelTest.testTrace.value"
:request-id="modelTest.requestId.value"
:request-headers-draft="testRequestHeadersDraft"
:request-headers-reset-value="testRequestHeadersResetValue"
:request-headers-error="testRequestHeadersError"
:request-body-draft="testRequestBodyDraft"
:request-body-reset-value="testRequestBodyResetValue"
:request-body-error="testRequestBodyError"
:start-disabled="!selectedTestEndpoint || !!testRequestBodyError"
:start-disabled="!selectedTestEndpoint || !!testRequestHeadersError || !!testRequestBodyError"
@close="handleTestDialogClose"
@back="handleTestDialogBack"
@start="handleStartPendingTest"
@select-endpoint="handleSelectTestEndpoint"
@update:request-headers-draft="testRequestHeadersDraft = $event"
@update:request-body-draft="testRequestBodyDraft = $event"
/>
</template>
@@ -252,8 +257,12 @@ import { formatApiFormat } from '@/api/endpoints/types/api-format'
import type { ProviderWithEndpointsSummary } from '@/api/endpoints'
import ModelTestDialog from './ModelTestDialog.vue'
import {
buildDefaultModelTestRequestHeaders,
buildDefaultModelTestRequestBody,
parseModelTestRequestHeadersDraft,
parseModelTestRequestBodyDraft,
POOL_TEST_CONCURRENCY,
SINGLE_TEST_CONCURRENCY,
} from './model-test-request'
const props = defineProps<{
@@ -280,8 +289,14 @@ const localModels = ref<Model[]>([])
const togglingModelId = ref<string | null>(null)
const pendingTestModel = ref<Model | null>(null)
const selectedTestEndpoint = ref<ProviderEndpoint | null>(null)
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 parsedTestRequestHeaders = computed(() => parseModelTestRequestHeadersDraft(testRequestHeadersDraft.value))
const testRequestHeadersError = computed(() => parsedTestRequestHeaders.value.error)
const parsedTestRequestBody = computed(() => parseModelTestRequestBodyDraft(testRequestBodyDraft.value))
const testRequestBodyError = computed(() => parsedTestRequestBody.value.error)
const models = computed(() => props.models ?? localModels.value)
@@ -449,7 +464,10 @@ function handleTestDialogClose() {
modelTest.resetState()
pendingTestModel.value = null
selectedTestEndpoint.value = null
testRequestHeadersDraft.value = ''
testRequestHeadersResetValue.value = ''
testRequestBodyDraft.value = ''
testRequestBodyResetValue.value = ''
}
function handleTestDialogBack() {
@@ -474,6 +492,12 @@ async function handleStartPendingTest() {
return
}
const { value: requestHeaders, error: requestHeadersError } = parsedTestRequestHeaders.value
if (!requestHeaders || requestHeadersError) {
showError(`测试请求头无效: ${requestHeadersError || '无效 JSON'}`)
return
}
const { value: requestBody, error } = parsedTestRequestBody.value
if (!requestBody || error) {
showError(`测试请求体无效: ${error || '无效 JSON'}`)
@@ -490,8 +514,9 @@ async function handleStartPendingTest() {
displayLabel: `${endpointPrefix}${modelName}`,
apiFormat: endpoint.api_format,
endpointId: endpoint.id,
requestHeaders,
requestBody,
concurrency: 5,
concurrency: isPoolManagedProvider.value ? POOL_TEST_CONCURRENCY : SINGLE_TEST_CONCURRENCY,
onError: () => {
if (activeEndpoints.value.length > 1) {
return true
@@ -510,9 +535,12 @@ async function testModelConnection(model: Model) {
pendingTestModel.value = model
selectedTestEndpoint.value = activeEndpoints.value[0] ?? null
testRequestBodyDraft.value = buildDefaultModelTestRequestBody(
testRequestHeadersResetValue.value = buildDefaultModelTestRequestHeaders()
testRequestHeadersDraft.value = testRequestHeadersResetValue.value
testRequestBodyResetValue.value = buildDefaultModelTestRequestBody(
model.global_model_name || model.provider_model_name,
)
testRequestBodyDraft.value = testRequestBodyResetValue.value
modelTest.testResult.value = null
modelTest.dialogOpen.value = true
}

View File

@@ -1,5 +1,9 @@
const DEFAULT_MODEL_TEST_MESSAGE = 'Hello! This is a test message.'
/** Pool-managed provider runs concurrent checks; single-key provider does not. */
export const POOL_TEST_CONCURRENCY = 5
export const SINGLE_TEST_CONCURRENCY = 1
export function buildDefaultModelTestRequestBody(modelName: string): string {
return JSON.stringify({
model: modelName,
@@ -15,14 +19,23 @@ export function buildDefaultModelTestRequestBody(modelName: string): string {
}, null, 2)
}
export function parseModelTestRequestBodyDraft(
export function buildDefaultModelTestRequestHeaders(): string {
return JSON.stringify({}, null, 2)
}
function parseModelTestJsonObjectDraft(
draft: string,
options: {
emptyValue: Record<string, unknown> | null
emptyError: string | null
invalidTypeError: string
},
): { value: Record<string, unknown> | null; error: string | null } {
const normalized = draft.trim()
if (!normalized) {
return {
value: null,
error: '测试请求体不能为空',
value: options.emptyValue,
error: options.emptyError,
}
}
@@ -31,7 +44,7 @@ export function parseModelTestRequestBodyDraft(
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
return {
value: null,
error: '测试请求体必须是 JSON 对象',
error: options.invalidTypeError,
}
}
return {
@@ -45,3 +58,23 @@ export function parseModelTestRequestBodyDraft(
}
}
}
export function parseModelTestRequestBodyDraft(
draft: string,
): { value: Record<string, unknown> | null; error: string | null } {
return parseModelTestJsonObjectDraft(draft, {
emptyValue: null,
emptyError: '测试请求体不能为空',
invalidTypeError: '测试请求体必须是 JSON 对象',
})
}
export function parseModelTestRequestHeadersDraft(
draft: string,
): { value: Record<string, unknown> | null; error: string | null } {
return parseModelTestJsonObjectDraft(draft, {
emptyValue: {},
emptyError: null,
invalidTypeError: '测试请求头必须是 JSON 对象',
})
}

View File

@@ -245,6 +245,7 @@ class TestModelFailoverRequest(BaseModel):
api_format: str | None = None # 指定 API 格式endpoint signature
endpoint_id: str | None = None # 指定仅使用该端点测试
message: str | None = None
request_headers: dict[str, Any] | None = None
request_body: dict[str, Any] | None = None
request_id: str | None = None
concurrency: int = Field(default=1, ge=1, le=20)
@@ -312,6 +313,31 @@ def _build_test_request_payload(request: TestModelFailoverRequest) -> dict[str,
}
def _build_test_request_headers(request: TestModelFailoverRequest) -> dict[str, str]:
if not isinstance(request.request_headers, dict):
return {}
headers: dict[str, str] = {}
for raw_key, raw_value in request.request_headers.items():
key = str(raw_key or "").strip()
if not key or raw_value is None:
continue
if isinstance(raw_value, str):
value = raw_value
elif isinstance(raw_value, (bool, int, float)):
value = str(raw_value)
else:
try:
value = json.dumps(raw_value, ensure_ascii=False)
except (TypeError, ValueError):
value = str(raw_value)
headers[key] = value
return headers
def _extract_test_debug_payload(response: dict[str, Any]) -> dict[str, Any] | None:
debug = response.get("debug")
if not isinstance(debug, dict):
@@ -1660,6 +1686,7 @@ async def _execute_test_check(
key: Any,
effective_model: str,
request_payload: dict[str, Any],
request_headers: dict[str, str] | None,
request_timeout: float,
provider_type: str,
user: User | None,
@@ -1679,6 +1706,8 @@ async def _execute_test_check(
auth_type = str(getattr(key, "auth_type", "api_key") or "api_key").lower()
extra_headers = get_extra_headers_from_endpoint(endpoint) or {}
if request_headers:
extra_headers.update(request_headers)
if auth_type == "oauth":
account_id = (auth_config or {}).get("account_id")
if account_id:
@@ -1738,6 +1767,7 @@ async def _run_concurrent_test(
is_cancelled: Callable[[], Awaitable[bool]],
request_id: str,
request_payload: dict[str, Any],
request_headers: dict[str, str] | None,
effective_model_by_candidate_index: dict[int, str],
request_timeout: float,
provider_type: str,
@@ -1873,6 +1903,7 @@ async def _run_concurrent_test(
str(request_payload.get("model", "") or ""),
),
request_payload=request_payload,
request_headers=request_headers,
request_timeout=request_timeout,
provider_type=provider_type,
user=user,
@@ -2354,6 +2385,7 @@ async def test_model_failover(
).model_dump()
request_payload = _build_test_request_payload(request)
request_headers = _build_test_request_headers(request)
request_id = str(request.request_id or f"provider-test-{uuid4().hex[:12]}")
request_timeout = float(getattr(provider, "request_timeout", 0) or TimeoutDefaults.HTTP_REQUEST)
provider_type = str(getattr(provider, "provider_type", "") or "").lower()
@@ -2372,6 +2404,7 @@ async def test_model_failover(
key=key,
effective_model=effective_model,
request_payload=request_payload,
request_headers=request_headers or None,
request_timeout=request_timeout,
provider_type=provider_type,
user=current_user,
@@ -2416,6 +2449,7 @@ async def test_model_failover(
is_cancelled=http_request.is_disconnected,
request_id=request_id,
request_payload=dict(request_payload),
request_headers=request_headers or None,
effective_model_by_candidate_index=effective_model_by_candidate_index,
request_timeout=request_timeout,
provider_type=provider_type,
@@ -2434,7 +2468,7 @@ async def test_model_failover(
is_stream=False,
capability_requirements=None,
request_body_state=MutableRequestBodyState(dict(request_payload)),
request_headers=None,
request_headers=request_headers or None,
request_body=dict(request_payload),
affinity_key=f"provider-test:{provider.id}",
create_pending_usage=False,