Merge remote-tracking branch 'upstream/main'

# Conflicts:
#	apps/aether-gateway/src/handlers/admin/provider/endpoints_admin/payloads.rs
#	apps/aether-gateway/src/handlers/admin/provider/endpoints_admin/reads.rs
#	apps/aether-gateway/src/handlers/admin/provider/endpoints_admin/update.rs
#	apps/aether-gateway/src/tests/control/admin/endpoints/routes.rs
#	frontend/src/features/models/components/GlobalModelFormDialog.vue
#	frontend/src/features/providers/components/ProviderModelFormDialog.vue
#	frontend/src/features/providers/components/provider-tabs/__tests__/model-test-request.spec.ts
#	frontend/src/features/providers/components/provider-tabs/model-test-request.ts
This commit is contained in:
ZheFox
2026-05-18 22:02:31 +08:00
102 changed files with 14682 additions and 2875 deletions
@@ -11,27 +11,13 @@
class="space-y-4"
@submit.prevent="handleSubmit"
>
<!-- 添加模式:选择或手动创建本地全局模型 -->
<!-- 添加模式:选择本地全局模型 -->
<div
v-if="!isEditing"
class="space-y-3"
>
<div class="flex items-center justify-between gap-3">
<Label for="global-model">选择已有模型或手动添加 *</Label>
<Button
type="button"
variant="ghost"
size="sm"
class="h-7 px-2 text-xs"
@click="manualGlobalModelMode = !manualGlobalModelMode"
>
{{ manualGlobalModelMode ? '选择已有模型' : '手动添加' }}
</Button>
</div>
<div
v-if="!manualGlobalModelMode"
class="space-y-2"
>
<div class="space-y-1.5">
<Label for="global-model">选择已有模型 *</Label>
<Select
:model-value="form.global_model_id"
:disabled="loadingGlobalModels"
@@ -51,44 +37,11 @@
</SelectContent>
</Select>
</div>
<div
v-else
class="rounded-lg border border-border/60 bg-muted/20 p-3 space-y-3"
>
<div class="grid grid-cols-2 gap-3">
<div class="space-y-1.5">
<Label
for="manual-global-model-name"
class="text-xs"
>模型ID *</Label>
<Input
id="manual-global-model-name"
v-model="form.manual_global_model_name"
placeholder="如 gpt-4o-mini"
@update:model-value="syncManualProviderName"
/>
</div>
<div class="space-y-1.5">
<Label
for="manual-global-model-display-name"
class="text-xs"
>显示名称</Label>
<Input
id="manual-global-model-display-name"
v-model="form.manual_global_model_display_name"
placeholder="默认使用模型ID"
/>
</div>
</div>
<p class="text-xs text-muted-foreground">
无法联网获取模型目录时,可直接填写模型ID。保存时会先创建本地全局模型,再添加到当前 Provider。
</p>
</div>
<p
v-if="availableGlobalModels.length === 0 && !loadingGlobalModels && !manualGlobalModelMode"
v-if="availableGlobalModels.length === 0 && !loadingGlobalModels"
class="text-xs text-muted-foreground"
>
没有可选择的本地全局模型。可以切换到“手动添加”继续保存。
没有可选择的本地全局模型。请先在模型管理中添加全局模型。
</p>
<div class="space-y-1.5">
<Label
@@ -320,7 +273,7 @@ import {
import { useToast } from '@/composables/useToast'
import { parseNumberInput, sortResolutionEntries } from '@/utils/form'
import { createModel, updateModel, getProviderModels } from '@/api/endpoints/models'
import { createGlobalModel, listGlobalModels, type GlobalModelResponse } from '@/api/global-models'
import { listGlobalModels, type GlobalModelResponse } from '@/api/global-models'
import TieredPricingEditor from '@/features/models/components/TieredPricingEditor.vue'
import type { Model, TieredPricingConfig } from '@/api/endpoints'
import {
@@ -387,7 +340,6 @@ const showCache1h = true
const submitting = ref(false)
const loadingGlobalModels = ref(false)
const availableGlobalModels = ref<GlobalModelResponse[]>([])
const manualGlobalModelMode = ref(false)
// 阶梯计费配置
const tieredPricing = ref<TieredPricingConfig | null>(null)
@@ -422,15 +374,9 @@ const VIDEO_RESOLUTION_PRICE_PRESETS: Record<
],
}
const DEFAULT_MANUAL_GLOBAL_MODEL_PRICING: TieredPricingConfig = {
tiers: [{ up_to: null, input_price_per_1m: 0, output_price_per_1m: 0 }],
}
const form = ref({
global_model_id: '',
provider_model_name: '',
manual_global_model_name: '',
manual_global_model_display_name: '',
price_per_request: undefined as number | undefined,
config: {} as Record<string, unknown>,
// 能力配置
@@ -446,7 +392,6 @@ const imageGenerationExplicitOverride = ref<boolean | null>(null)
const canSubmitCreate = computed(() => {
if (isEditing.value) return true
if (!form.value.provider_model_name.trim()) return false
if (manualGlobalModelMode.value) return !!form.value.manual_global_model_name.trim()
return !!form.value.global_model_id
})
@@ -462,8 +407,6 @@ watch(() => props.open, async (newOpen) => {
form.value = {
global_model_id: props.editingModel.global_model_id || '',
provider_model_name: props.editingModel.provider_model_name || '',
manual_global_model_name: '',
manual_global_model_display_name: '',
// 显示有效的按次计费价格(继承自全局模型)
price_per_request: props.editingModel.effective_price_per_request ?? props.editingModel.price_per_request ?? undefined,
config: effectiveConfig ? JSON.parse(JSON.stringify(effectiveConfig)) : {},
@@ -526,8 +469,6 @@ function resetForm() {
form.value = {
global_model_id: '',
provider_model_name: '',
manual_global_model_name: '',
manual_global_model_display_name: '',
price_per_request: undefined,
config: {},
supports_vision: undefined,
@@ -543,7 +484,6 @@ function resetForm() {
tieredPricingModified.value = false
originalTieredPricing.value = ''
availableGlobalModels.value = []
manualGlobalModelMode.value = false
}
function handleGlobalModelSelect(value: string) {
@@ -554,16 +494,6 @@ function handleGlobalModelSelect(value: string) {
form.value.provider_model_name = selectedModel?.name || form.value.provider_model_name
}
function syncManualProviderName(value: string | number) {
const modelName = String(value || '').trim()
if (!form.value.provider_model_name.trim()) {
form.value.provider_model_name = modelName
}
if (!form.value.manual_global_model_display_name.trim()) {
form.value.manual_global_model_display_name = modelName
}
}
function modelSupportsImageGeneration(model: {
supported_capabilities?: string[] | null
supports_image_generation?: boolean | null
@@ -753,32 +683,6 @@ function _copyVideoPricingFromSelectedGlobal() {
configTouched.value = true
}
async function createManualGlobalModel(
finalTieredPricing: TieredPricingConfig | null,
cleanConfig: Record<string, unknown> | undefined,
supportsImageGeneration: boolean,
): Promise<GlobalModelResponse> {
const modelName = form.value.manual_global_model_name.trim()
const displayName = form.value.manual_global_model_display_name.trim() || modelName
const supportedCapabilities = [
form.value.supports_vision === true ? 'vision' : null,
form.value.supports_function_calling === true ? 'function_calling' : null,
form.value.supports_streaming === true ? 'streaming' : null,
form.value.supports_extended_thinking === true ? 'extended_thinking' : null,
supportsImageGeneration ? 'image_generation' : null,
].filter((capability): capability is string => capability !== null)
return createGlobalModel({
name: modelName,
display_name: displayName,
default_price_per_request: form.value.price_per_request,
default_tiered_pricing: finalTieredPricing || DEFAULT_MANUAL_GLOBAL_MODEL_PRICING,
supported_capabilities: supportedCapabilities.length ? supportedCapabilities : undefined,
config: cleanConfig,
is_active: true,
})
}
// 加载可用的全局模型(排除已添加的)
async function loadAvailableGlobalModels() {
loadingGlobalModels.value = true
@@ -816,7 +720,7 @@ function handleClose(value: boolean) {
async function handleSubmit() {
if (submitting.value) return
if (!isEditing.value && !canSubmitCreate.value) {
showError(manualGlobalModelMode.value ? '请填写模型ID和 Provider 模型名' : '请选择模型并填写 Provider 模型名', '错误')
showError('请选择模型并填写 Provider 模型名', '错误')
return
}
@@ -850,21 +754,19 @@ async function handleSubmit() {
showSuccess('模型配置已更新')
} else {
// 添加模式:只有用户修改了配置才提交 tiered_pricing,否则保持继承关系
const selectedModel = manualGlobalModelMode.value
? await createManualGlobalModel(finalTieredPricing, cleanConfig, supportsImageGeneration)
: availableGlobalModels.value.find(m => m.id === form.value.global_model_id)
const selectedModel = availableGlobalModels.value.find(m => m.id === form.value.global_model_id)
if (!selectedModel) {
showError('请选择模型,或切换到手动添加后填写模型ID', '错误')
showError('请选择模型', '错误')
return
}
await createModel(props.providerId, buildProviderModelCreatePayload({
globalModelId: selectedModel.id,
providerModelName: form.value.provider_model_name.trim(),
finalTieredPricing,
tieredPricingModified: manualGlobalModelMode.value ? false : tieredPricingModified.value,
pricePerRequest: manualGlobalModelMode.value ? undefined : form.value.price_per_request,
tieredPricingModified: tieredPricingModified.value,
pricePerRequest: form.value.price_per_request,
cleanConfig,
configTouched: manualGlobalModelMode.value ? false : configTouched.value,
configTouched: configTouched.value,
supportsVision: form.value.supports_vision,
supportsFunctionCalling: form.value.supports_function_calling,
supportsStreaming: form.value.supports_streaming,
@@ -49,7 +49,7 @@ describe('provider model form embedding helpers', () => {
expect('supports_embedding' in payload).toBe(false)
})
it('uses manually supplied provider model name in create payload', () => {
it('uses supplied provider model name in create payload', () => {
const payload = buildProviderModelCreatePayload({
globalModelId: 'gm-local-manual',
providerModelName: 'intranet-chat-model-v1',
@@ -374,6 +374,7 @@ import {
isModelTestableEndpoint,
parseModelTestRequestHeadersDraft,
parseModelTestRequestBodyDraft,
selectPreferredModelTestEndpoint,
syncModelTestRequestBodyDraft,
} from './model-test-request'
@@ -428,6 +429,7 @@ const deletingGroup = ref<AliasGroup | null>(null)
const testingMapping = ref<string | null>(null)
const pendingMappingKey = ref<string | null>(null)
const testingModelName = ref<string | null>(null)
const testingSourceModel = ref<Model | null>(null)
const preselectedModelId = ref<string | null>(null)
const selectedTestEndpoint = ref<ProviderEndpoint | null>(null)
const testRequestHeadersDraft = ref('')
@@ -716,6 +718,7 @@ function handleTestDialogClose() {
modelTest.resetState()
pendingMappingKey.value = null
testingModelName.value = null
testingSourceModel.value = null
testingMapping.value = null
selectedTestEndpoint.value = null
mappingTestEndpoints.value = null
@@ -738,8 +741,25 @@ function handleSelectTestEndpoint(endpointId: string) {
syncMappingTestRequestBody()
}
function findMappingTestModel(modelName: string): Model | null {
const normalized = modelName.trim()
if (!normalized) return null
return models.value.find(model => (
model.provider_model_name === normalized
|| model.global_model_name === normalized
|| model.global_model_display_name === normalized
|| (model.provider_model_mappings ?? []).some(alias => alias.name === normalized)
)) ?? null
}
// 测试映射(直连测试,带故障转移和实时进度)
function runMappingTest(testingKey: string, modelName: string, endpointsOverride?: ProviderEndpoint[]) {
function runMappingTest(
testingKey: string,
modelName: string,
endpointsOverride?: ProviderEndpoint[],
sourceModel?: Model | null,
) {
const endpoints = endpointsOverride ?? activeEndpoints.value
if (endpoints.length === 0) {
showError('暂无可用于测试的活跃端点')
@@ -750,8 +770,12 @@ function runMappingTest(testingKey: string, modelName: string, endpointsOverride
modelTest.dialogOpen.value = true
testingMapping.value = null
testingModelName.value = modelName
testingSourceModel.value = sourceModel ?? findMappingTestModel(modelName)
mappingTestEndpoints.value = endpointsOverride ?? null
selectedTestEndpoint.value = endpoints[0] ?? null
selectedTestEndpoint.value = selectPreferredModelTestEndpoint(
testingSourceModel.value,
endpoints,
)
testRequestHeadersResetValue.value = buildDefaultModelTestRequestHeaders()
testRequestHeadersDraft.value = testRequestHeadersResetValue.value
resetMappingTestRequestBody()
@@ -763,6 +787,7 @@ function resetMappingTestRequestBody() {
testRequestBodyResetValue.value = buildDefaultModelTestRequestBody(
testingModelName.value,
selectedTestEndpoint.value?.api_format,
testingSourceModel.value,
)
testRequestBodyDraft.value = testRequestBodyResetValue.value
}
@@ -773,6 +798,7 @@ function syncMappingTestRequestBody() {
const nextResetValue = buildDefaultModelTestRequestBody(
testingModelName.value,
selectedTestEndpoint.value?.api_format,
testingSourceModel.value,
)
const next = syncModelTestRequestBodyDraft(
testRequestBodyDraft.value,
@@ -840,7 +866,7 @@ function scopedMappingEndpoints(item: CombinedMapping): ProviderEndpoint[] {
// 测试精确映射
function testMapping(item: CombinedMapping, mapping: MappingItem) {
runMappingTest(`${item.key}-${mapping.name}`, mapping.name, scopedMappingEndpoints(item))
runMappingTest(`${item.key}-${mapping.name}`, mapping.name, scopedMappingEndpoints(item), item.group?.model)
}
// 测试正则映射
@@ -444,7 +444,27 @@
{{ attempt.key_name || maskKey(attempt.key_id) }}
</div>
<div
v-if="attemptDetail(attempt) !== '-'"
v-if="attemptImagePreviews(attempt).length > 0"
class="mt-2 flex flex-wrap gap-2"
>
<button
v-for="(preview, imageIndex) in attemptImagePreviews(attempt).slice(0, 3)"
:key="`${preview.src}-${imageIndex}`"
type="button"
class="h-16 w-16 overflow-hidden rounded-md border border-border/60 bg-muted/30 transition-colors hover:border-primary/60"
:title="preview.label"
@click.stop="openImagePreview(preview)"
>
<img
:src="preview.src"
:alt="preview.label"
class="h-full w-full object-contain"
loading="lazy"
>
</button>
</div>
<div
v-else-if="attemptDetail(attempt) !== '-'"
class="mt-1 break-all text-muted-foreground"
>
{{ attemptDetail(attempt) }}
@@ -519,6 +539,33 @@
</td>
<td class="px-3 py-2 text-muted-foreground">
<div
v-if="attemptImagePreviews(attempt).length > 0"
class="flex flex-wrap gap-2"
>
<button
v-for="(preview, imageIndex) in attemptImagePreviews(attempt).slice(0, 4)"
:key="`${preview.src}-${imageIndex}`"
type="button"
class="h-14 w-14 overflow-hidden rounded-md border border-border/60 bg-muted/30 transition-colors hover:border-primary/60 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/70"
:title="preview.label"
@click.stop="openImagePreview(preview)"
>
<img
:src="preview.src"
:alt="preview.label"
class="h-full w-full object-contain"
loading="lazy"
>
</button>
<span
v-if="attemptImagePreviews(attempt).length > 4"
class="flex h-14 items-center rounded-md border border-border/60 px-2 text-xs text-muted-foreground"
>
+{{ attemptImagePreviews(attempt).length - 4 }}
</span>
</div>
<div
v-else
class="line-clamp-2 break-all"
:title="attemptDetail(attempt)"
>
@@ -641,6 +688,36 @@
</TabsContent>
<TabsContent value="response-body">
<div
v-if="selectedInspectionImagePreviews.length > 0"
class="mb-3 rounded-md border border-border/60 bg-muted/20 p-3"
>
<div class="mb-3 flex items-center justify-between gap-3 text-xs text-muted-foreground">
<span>图片预览</span>
<span>{{ selectedInspectionImagePreviews.length }} 张</span>
</div>
<div class="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
<button
v-for="(preview, index) in selectedInspectionImagePreviews"
:key="`${preview.src}-${index}`"
type="button"
class="group block overflow-hidden rounded-md border border-border/60 bg-background text-left transition-colors hover:border-primary/60 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/70"
@click="openImagePreview(preview)"
>
<div class="aspect-square w-full overflow-hidden bg-muted/30">
<img
:src="preview.src"
:alt="preview.label"
class="h-full w-full object-contain"
loading="lazy"
>
</div>
<div class="border-t border-border/60 px-2 py-1 text-[11px] text-muted-foreground">
{{ preview.label }}
</div>
</button>
</div>
</div>
<JsonContent
:data="selectedInspectionAttempt.response_body"
view-mode="formatted"
@@ -673,6 +750,39 @@
</Button>
</template>
</Dialog>
<Dialog
:open="Boolean(activeImagePreview)"
size="6xl"
:z-index="120"
@update:open="(val: boolean) => { if (!val) activeImagePreview = null }"
>
<template #header>
<div class="border-b border-border px-6 py-4">
<div class="text-lg font-semibold text-foreground leading-tight">
{{ activeImagePreview?.label || '图片预览' }}
</div>
</div>
</template>
<div class="flex max-h-[76vh] items-center justify-center overflow-auto rounded-md bg-muted/20 p-3">
<img
v-if="activeImagePreview"
:src="activeImagePreview.src"
:alt="activeImagePreview.label"
class="max-h-[72vh] max-w-full rounded-md object-contain"
>
</div>
<template #footer>
<Button
variant="outline"
@click="activeImagePreview = null"
>
关闭
</Button>
</template>
</Dialog>
</template>
<script setup lang="ts">
@@ -698,7 +808,12 @@ import type { CandidateRecord, RequestTrace } from '@/api/requestTrace'
import JsonContent from '@/features/usage/components/RequestDetailDrawer/JsonContent.vue'
import { useClipboard } from '@/composables/useClipboard'
import { useDarkMode } from '@/composables/useDarkMode'
import { extractModelTestResponsePreview, formatModelTestDiagnostic } from './model-test-request'
import {
extractModelTestImagePreviews,
extractModelTestResponsePreview,
formatModelTestDiagnostic,
} from './model-test-request'
import type { ModelTestImagePreview } from './model-test-request'
type TestEndpointOption = {
id: string
@@ -816,6 +931,7 @@ const inspectionTab = ref<'request-headers' | 'request-body' | 'response-headers
const selectedInspectionKey = ref<string | null>(null)
const inspectionExpandDepth = ref(0)
const inspectionCopiedStates = ref<Record<string, boolean>>({})
const activeImagePreview = ref<ModelTestImagePreview | null>(null)
watch(() => props.result, () => {
showAllAttempts.value = false
@@ -1129,6 +1245,12 @@ const selectedInspectionAttempt = computed(() => {
return inspectableAttempts.value[0] ?? resultAttempts.value[0] ?? null
})
const selectedInspectionImagePreviews = computed(() => (
selectedInspectionAttempt.value
? extractModelTestImagePreviews(selectedInspectionAttempt.value.response_body)
: []
))
const resultWinningTitle = computed(() => {
const summary = resultSummary.value
const keyName = summary.winning_key_name || summary.winning_key_id
@@ -1238,6 +1360,7 @@ function formatAuthType(authType: string): string {
if (lowered === 'codex') return 'Codex OAuth'
if (lowered === 'antigravity') return 'Antigravity OAuth'
if (lowered === 'kiro') return 'Kiro OAuth'
if (lowered === 'grok') return 'Grok OAuth'
return authType
}
@@ -1295,6 +1418,14 @@ function attemptDetail(attempt: TestAttemptDetail): string {
return '-'
}
function attemptImagePreviews(attempt: TestAttemptDetail): ModelTestImagePreview[] {
return extractModelTestImagePreviews(attempt.response_body)
}
function openImagePreview(preview: ModelTestImagePreview) {
activeImagePreview.value = preview
}
function inspectionKey(attempt: TestAttemptDetail): string {
return `${attempt.candidate_index}:${attempt.retry_index ?? 0}:${attempt.key_id}`
}
@@ -272,6 +272,7 @@ import {
normalizeModelTestMappedModelSelection,
parseModelTestRequestHeadersDraft,
parseModelTestRequestBodyDraft,
selectPreferredModelTestEndpoint,
syncModelTestRequestBodyDraft,
} from './model-test-request'
@@ -587,7 +588,7 @@ async function testModelConnection(model: Model) {
}
pendingTestModel.value = model
selectedTestEndpoint.value = activeEndpoints.value[0] ?? null
selectedTestEndpoint.value = selectPreferredModelTestEndpoint(model, activeEndpoints.value)
const requestedModelName = getModelTestRequestedModelName(model)
selectedTestMappedModelName.value = null
testRequestHeadersResetValue.value = buildDefaultModelTestRequestHeaders()
@@ -595,6 +596,7 @@ async function testModelConnection(model: Model) {
testRequestBodyResetValue.value = buildDefaultModelTestRequestBody(
requestedModelName,
selectedTestEndpoint.value?.api_format,
model,
)
testRequestBodyDraft.value = testRequestBodyResetValue.value
modelTest.testResult.value = null
@@ -620,7 +622,11 @@ function syncTestRequestBodyModel() {
if (!modelName) return
const resetDraft = testRequestBodyResetValue.value
|| buildDefaultModelTestRequestBody(modelName, selectedTestEndpoint.value?.api_format)
|| buildDefaultModelTestRequestBody(
modelName,
selectedTestEndpoint.value?.api_format,
pendingTestModel.value,
)
const next = syncModelTestRequestBodyDraft(
testRequestBodyDraft.value,
testRequestBodyResetValue.value,
@@ -638,6 +644,7 @@ function resetTestRequestBodyForSelectedEndpoint() {
const nextResetValue = buildDefaultModelTestRequestBody(
modelName,
selectedTestEndpoint.value?.api_format,
pendingTestModel.value,
)
const next = syncModelTestRequestBodyDraft(
testRequestBodyDraft.value,
@@ -3,12 +3,15 @@ import { describe, expect, it } from 'vitest'
import {
buildDefaultModelTestRequestBody,
buildExactModelMappingTestRequest,
extractModelTestImagePreviews,
extractModelTestResponsePreview,
formatModelTestDiagnostic,
getOpenAiImageModelTestMaxGenerationCount,
isModelTestableEndpoint,
isModelTestableApiFormat,
listModelTestMappedModelOptions,
normalizeModelTestMappedModelSelection,
selectPreferredModelTestEndpoint,
setModelTestRequestBodyModel,
syncModelTestRequestBodyDraft,
} from '../model-test-request'
@@ -68,6 +71,28 @@ describe('buildDefaultModelTestRequestBody', () => {
expect(body.messages).toBeUndefined()
})
it('uses image generation tools for image models on OpenAI Responses endpoints', () => {
const body = JSON.parse(buildDefaultModelTestRequestBody(
'gpt-image-2',
'openai:responses',
{
effective_supports_image_generation: true,
},
))
expect(body.model).toBe('gpt-image-2')
expect(body.input).toBe('Hello! This is a test message.')
expect(body.tools).toEqual([
{
type: 'image_generation',
size: '1024x1024',
output_format: 'png',
},
])
expect(body.tool_choice).toEqual({ type: 'image_generation' })
expect(body.messages).toBeUndefined()
})
it('lists endpoint-scoped provider model mappings in test selection order', () => {
const options = listModelTestMappedModelOptions({
provider_model_name: 'claude-opus-4-6',
@@ -219,6 +244,7 @@ describe('isModelTestableApiFormat', () => {
'openai:image',
'claude:messages',
'gemini:generate_content',
'openai:image',
'openai:embedding',
'jina:rerank',
])('allows synchronous model-test endpoint formats: %s', (apiFormat) => {
@@ -226,6 +252,75 @@ describe('isModelTestableApiFormat', () => {
})
})
describe('selectPreferredModelTestEndpoint', () => {
it('prefers openai image endpoints for image generation models', () => {
const chatEndpoint = { id: 'chat', api_format: 'openai:chat', is_active: true }
const imageEndpoint = { id: 'image', api_format: 'openai:image', is_active: true }
expect(selectPreferredModelTestEndpoint({
effective_supports_image_generation: true,
}, [chatEndpoint, imageEndpoint])).toBe(imageEndpoint)
})
it('prefers openai image endpoints from model-test capability metadata', () => {
const chatEndpoint = { id: 'chat', api_format: 'openai:chat', is_active: true }
const imageEndpoint = { id: 'image', api_format: 'openai:image', is_active: true }
expect(selectPreferredModelTestEndpoint({
effective_supports_image_generation: false,
model_test_capabilities: {
'openai:image': {
supports_generation: true,
max_generation_count: 4,
},
},
}, [chatEndpoint, imageEndpoint])).toBe(imageEndpoint)
})
it('does not treat edit-only image capability as generation support', () => {
const chatEndpoint = { id: 'chat', api_format: 'openai:chat', is_active: true }
const imageEndpoint = { id: 'image', api_format: 'openai:image', is_active: true }
expect(selectPreferredModelTestEndpoint({
effective_supports_image_generation: true,
model_test_capabilities: {
'openai:image': {
supports_generation: false,
supports_edit: true,
max_generation_count: 4,
},
},
}, [chatEndpoint, imageEndpoint])).toBe(chatEndpoint)
})
it('keeps the existing endpoint order for non-image models', () => {
const chatEndpoint = { id: 'chat', api_format: 'openai:chat', is_active: true }
const imageEndpoint = { id: 'image', api_format: 'openai:image', is_active: true }
expect(selectPreferredModelTestEndpoint({
effective_supports_image_generation: false,
}, [chatEndpoint, imageEndpoint])).toBe(chatEndpoint)
})
})
describe('getOpenAiImageModelTestMaxGenerationCount', () => {
it('reads image generation count from backend capability metadata', () => {
expect(getOpenAiImageModelTestMaxGenerationCount({
model_test_capabilities: {
'openai:image': {
max_generation_count: 4,
},
},
})).toBe(4)
})
it('returns null when backend capability metadata is absent', () => {
expect(getOpenAiImageModelTestMaxGenerationCount({
effective_supports_image_generation: true,
})).toBeNull()
})
})
describe('isModelTestableEndpoint', () => {
it('requires at least one active key compatible with the endpoint format', () => {
const keys = [
@@ -373,6 +468,95 @@ describe('extractModelTestResponsePreview', () => {
})).toBe('Rerank 结果:2 条')
})
it('extracts image URLs from OpenAI image responses', () => {
expect(extractModelTestResponsePreview({
data: [
{
url: 'https://example.com/generated.png',
revised_prompt: 'A generated image',
},
],
})).toBe('图片:https://example.com/generated.png')
})
it('summarizes base64 image responses without dumping the image payload', () => {
expect(extractModelTestResponsePreview({
data: [
{
b64_json: 'aGVsbG8=',
},
],
})).toBe('图片:base64')
})
it('extracts renderable base64 image previews from OpenAI image responses', () => {
expect(extractModelTestImagePreviews({
data: [
{
b64_json: 'aGVsbG8=',
mime_type: 'image/jpeg',
},
],
})).toEqual([
{
src: 'data:image/jpeg;base64,aGVsbG8=',
label: '图片 1',
source: 'base64',
},
])
})
it('extracts image previews from nested response image urls', () => {
expect(extractModelTestImagePreviews({
output: [
{
content: [
{
type: 'output_image',
image_url: {
url: 'https://example.com/generated.png',
},
},
],
},
],
})).toEqual([
{
src: 'https://example.com/generated.png',
label: '图片 1',
source: 'url',
},
])
})
it('extracts image previews from OpenAI Responses image_generation_call results', () => {
expect(extractModelTestResponsePreview({
output: [
{
type: 'image_generation_call',
output_format: 'png',
result: 'aGVsbG8=',
},
],
})).toBe('图片:base64')
expect(extractModelTestImagePreviews({
output: [
{
type: 'image_generation_call',
output_format: 'png',
result: 'aGVsbG8=',
},
],
})).toEqual([
{
src: 'data:image/png;base64,aGVsbG8=',
label: '图片 1',
source: 'base64',
},
])
})
it('falls back to response model when no text payload exists', () => {
expect(extractModelTestResponsePreview({
model: 'glm-4.5-air',
@@ -0,0 +1,111 @@
import { normalizeApiFormatAlias } from '@/api/endpoints/types/api-format'
import type { ModelTestCapabilities, OpenAiImageModelTestCapability } from '@/api/endpoints/types'
export type ModelTestEndpointSource = {
api_format: string
is_active?: boolean | null
}
export type ModelTestImageSource = {
effective_supports_image_generation?: boolean | null
supports_image_generation?: boolean | null
model_test_capabilities?: ModelTestCapabilities | null
}
export type ModelTestKeySource = {
api_formats?: string[] | null
is_active?: boolean | null
}
const MODEL_TEST_UNSUPPORTED_API_FORMATS = new Set([
'openai:video',
'gemini:video',
'gemini:files',
])
const MODEL_TEST_DIAGNOSTIC_LABELS: Record<string, string> = {
pool_account_blocked: '账号已失效,需重新授权',
}
export function normalizeModelTestStringList(values: string[] | null | undefined): string[] {
return (values ?? [])
.map(value => value.trim())
.filter(Boolean)
}
export function isModelTestableApiFormat(apiFormat: string | null | undefined): boolean {
const normalized = normalizeApiFormatAlias(apiFormat ?? '')
return Boolean(normalized) && !MODEL_TEST_UNSUPPORTED_API_FORMATS.has(normalized)
}
export function modelTestKeySupportsEndpoint(
key: ModelTestKeySource,
endpoint: ModelTestEndpointSource,
): boolean {
if (key.is_active === false) return false
const endpointFormat = normalizeApiFormatAlias(endpoint.api_format)
if (!isModelTestableApiFormat(endpointFormat)) return false
const keyFormats = normalizeModelTestStringList(key.api_formats)
if (keyFormats.length === 0) return true
return keyFormats.some(format => normalizeApiFormatAlias(format) === endpointFormat)
}
export function isModelTestableEndpoint(
endpoint: ModelTestEndpointSource,
keys: ModelTestKeySource[],
): boolean {
return endpoint.is_active !== false
&& isModelTestableApiFormat(endpoint.api_format)
&& keys.some(key => modelTestKeySupportsEndpoint(key, endpoint))
}
export function selectPreferredModelTestEndpoint<T extends ModelTestEndpointSource>(
model: ModelTestImageSource | null | undefined,
endpoints: T[],
): T | null {
if (modelSupportsImageGeneration(model)) {
const imageEndpoint = endpoints.find(
endpoint => normalizeApiFormatAlias(endpoint.api_format) === 'openai:image',
)
if (imageEndpoint) return imageEndpoint
}
return endpoints[0] ?? null
}
export function getOpenAiImageModelTestCapability(
model: ModelTestImageSource | null | undefined,
): OpenAiImageModelTestCapability | null {
const capability = model?.model_test_capabilities?.['openai:image']
return capability && typeof capability === 'object'
? capability as OpenAiImageModelTestCapability
: null
}
export function getOpenAiImageModelTestMaxGenerationCount(
model: ModelTestImageSource | null | undefined,
): number | null {
const maxGenerationCount = getOpenAiImageModelTestCapability(model)?.max_generation_count
return typeof maxGenerationCount === 'number' && Number.isFinite(maxGenerationCount)
? Math.max(1, Math.floor(maxGenerationCount))
: null
}
export function formatModelTestDiagnostic(value: string | null | undefined): string {
const normalized = value?.trim()
if (!normalized) return ''
return MODEL_TEST_DIAGNOSTIC_LABELS[normalized] ?? normalized
}
export function modelSupportsImageGeneration(model: ModelTestImageSource | null | undefined): boolean {
const imageCapability = getOpenAiImageModelTestCapability(model)
if (imageCapability) {
return imageCapability.supports_generation !== false
}
return Boolean(
model?.effective_supports_image_generation ?? model?.supports_image_generation,
)
}
@@ -0,0 +1,457 @@
const MODEL_TEST_RESPONSE_PREVIEW_MAX_LENGTH = 160
const MODEL_TEST_IMAGE_PREVIEW_MAX_ITEMS = 6
type JsonRecord = Record<string, unknown>
export type ModelTestImagePreview = {
src: string
label: string
source: 'base64' | 'url'
}
export function extractModelTestResponsePreview(responseBody: unknown): string | null {
const text = extractResponseText(responseBody)
if (text) return text
const reasoning = extractResponseReasoning(responseBody)
if (reasoning) return `推理:${reasoning}`
const image = extractImagePreview(responseBody)
if (image) return image
const summary = extractResponseSummary(responseBody)
if (summary) return summary
return null
}
export function extractModelTestImagePreviews(responseBody: unknown): ModelTestImagePreview[] {
const previews: ModelTestImagePreview[] = []
collectImagePreviews(responseBody, previews, new Set(), 0)
return previews
}
function isJsonRecord(value: unknown): value is JsonRecord {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
}
function compactPreviewText(value: unknown): string | null {
if (typeof value !== 'string') return null
const normalized = value.replace(/\s+/g, ' ').trim()
if (!normalized) return null
if (normalized.length <= MODEL_TEST_RESPONSE_PREVIEW_MAX_LENGTH) {
return normalized
}
return `${normalized.slice(0, MODEL_TEST_RESPONSE_PREVIEW_MAX_LENGTH - 3)}...`
}
function joinPreviewParts(parts: string[]): string | null {
return compactPreviewText(parts.filter(Boolean).join(' '))
}
function extractTextFromContentParts(value: unknown, depth = 0): string | null {
if (depth > 4) return null
const directText = compactPreviewText(value)
if (directText) return directText
if (!Array.isArray(value)) return null
const parts = value.flatMap((part) => {
if (typeof part === 'string') return [part]
if (!isJsonRecord(part)) return []
const text = compactPreviewText(part.text)
?? compactPreviewText(part.content)
?? extractTextFromContentParts(part.parts, depth + 1)
return text ? [text] : []
})
return joinPreviewParts(parts)
}
function extractResponseText(responseBody: unknown, depth = 0): string | null {
if (depth > 4 || !isJsonRecord(responseBody)) return null
const wrappedText = extractResponseText(responseBody.response, depth + 1)
?? extractResponseText(responseBody.body, depth + 1)
if (wrappedText) return wrappedText
const outputText = compactPreviewText(responseBody.output_text)
if (outputText) return outputText
const topLevelContentText = extractTextFromContentParts(responseBody.content, depth + 1)
if (topLevelContentText) return topLevelContentText
const choicesText = extractChoicesText(responseBody.choices, depth + 1)
if (choicesText) return choicesText
const outputTextParts = extractOutputText(responseBody.output, depth + 1)
if (outputTextParts) return outputTextParts
const candidateText = extractGeminiCandidateText(responseBody.candidates, depth + 1)
if (candidateText) return candidateText
return null
}
function extractImagePreview(responseBody: unknown, depth = 0): string | null {
if (depth > 4 || !isJsonRecord(responseBody)) return null
const wrappedPreview = extractImagePreview(responseBody.response, depth + 1)
?? extractImagePreview(responseBody.body, depth + 1)
if (wrappedPreview) return wrappedPreview
const dataPreview = extractImagePreviewFromCollection(responseBody.data, depth + 1)
if (dataPreview) return dataPreview
const outputPreview = extractImagePreviewFromCollection(responseBody.output, depth + 1)
if (outputPreview) return outputPreview
const imagesPreview = extractImagePreviewFromCollection(responseBody.images, depth + 1)
if (imagesPreview) return imagesPreview
const contentPreview = extractImagePreviewFromContentParts(responseBody.content, depth + 1)
if (contentPreview) return contentPreview
return null
}
function collectImagePreviews(
value: unknown,
previews: ModelTestImagePreview[],
seen: Set<string>,
depth: number,
) {
if (depth > 5 || previews.length >= MODEL_TEST_IMAGE_PREVIEW_MAX_ITEMS || value == null) return
if (Array.isArray(value)) {
for (const item of value) {
collectImagePreviews(item, previews, seen, depth + 1)
if (previews.length >= MODEL_TEST_IMAGE_PREVIEW_MAX_ITEMS) return
}
return
}
if (!isJsonRecord(value)) return
collectImagePreviewFromRecord(value, previews, seen, depth)
const nestedValues = [
value.response,
value.body,
value.data,
value.output,
value.images,
value.content,
]
for (const nested of nestedValues) {
collectImagePreviews(nested, previews, seen, depth + 1)
if (previews.length >= MODEL_TEST_IMAGE_PREVIEW_MAX_ITEMS) return
}
}
function collectImagePreviewFromRecord(
value: JsonRecord,
previews: ModelTestImagePreview[],
seen: Set<string>,
depth: number,
) {
const mime = imageMimeFromRecord(value)
const imageUrl = value.image_url
if (typeof imageUrl === 'string') {
pushImagePreview(previews, seen, imageUrlToPreview(imageUrl, 'url'))
} else if (isJsonRecord(imageUrl)) {
pushImagePreview(previews, seen, imageUrlToPreview(imageUrl.url, 'url'))
pushImagePreview(previews, seen, base64ImageToPreview(imageUrl.b64_json, imageMimeFromRecord(imageUrl)))
}
pushImagePreview(previews, seen, imageUrlToPreview(value.url, 'url'))
pushImagePreview(previews, seen, base64ImageToPreview(value.b64_json, mime))
pushImagePreview(previews, seen, base64ImageToPreview(value.data, mime))
if (value.type === 'image_generation_call') {
pushImagePreview(previews, seen, base64ImageToPreview(value.result, mime))
}
if (depth <= 4) {
collectImagePreviews(value.source, previews, seen, depth + 1)
}
}
function pushImagePreview(
previews: ModelTestImagePreview[],
seen: Set<string>,
preview: ModelTestImagePreview | null,
) {
if (!preview || seen.has(preview.src) || previews.length >= MODEL_TEST_IMAGE_PREVIEW_MAX_ITEMS) {
return
}
seen.add(preview.src)
previews.push({
...preview,
label: `图片 ${previews.length + 1}`,
})
}
function imageUrlToPreview(value: unknown, source: 'url'): ModelTestImagePreview | null {
if (typeof value !== 'string') return null
const url = value.trim()
if (!url) return null
if (url.startsWith('data:image/')) {
return { src: url, label: 'base64', source: 'base64' }
}
try {
const parsed = new URL(url)
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return null
} catch {
return null
}
return { src: url, label: 'URL', source }
}
function base64ImageToPreview(value: unknown, mime: string): ModelTestImagePreview | null {
if (typeof value !== 'string') return null
const trimmed = value.trim()
if (!trimmed) return null
if (trimmed.startsWith('data:image/')) {
return { src: trimmed, label: 'base64', source: 'base64' }
}
const normalized = trimmed.replace(/\s+/g, '')
if (!normalized) return null
return {
src: `data:${mime};base64,${normalized}`,
label: 'base64',
source: 'base64',
}
}
function imageMimeFromRecord(value: JsonRecord): string {
const outputFormat = value.output_format
if (typeof outputFormat === 'string') {
const normalized = outputFormat.trim().toLowerCase()
if (/^[a-z0-9.+-]+$/.test(normalized)) return `image/${normalized}`
}
const raw = [
value.mime_type,
value.mime,
value.media_type,
value.content_type,
value.type,
].find(candidate => typeof candidate === 'string' && candidate.trim().startsWith('image/'))
if (typeof raw === 'string') {
const normalized = raw.trim().toLowerCase()
if (/^image\/[a-z0-9.+-]+$/.test(normalized)) return normalized
}
return 'image/png'
}
function extractResponseReasoning(responseBody: unknown, depth = 0): string | null {
if (depth > 4 || !isJsonRecord(responseBody)) return null
const wrappedReasoning = extractResponseReasoning(responseBody.response, depth + 1)
?? extractResponseReasoning(responseBody.body, depth + 1)
if (wrappedReasoning) return wrappedReasoning
const directReasoning = compactPreviewText(responseBody.reasoning_content)
?? compactPreviewText(responseBody.thinking)
if (directReasoning) return directReasoning
const topLevelReasoning = extractReasoningFromContentParts(responseBody.content, depth + 1)
if (topLevelReasoning) return topLevelReasoning
const choicesReasoning = extractChoicesReasoning(responseBody.choices, depth + 1)
if (choicesReasoning) return choicesReasoning
const outputReasoning = extractOutputReasoning(responseBody.output, depth + 1)
if (outputReasoning) return outputReasoning
return null
}
function extractChoicesText(value: unknown, depth: number): string | null {
if (!Array.isArray(value)) return null
for (const choice of value) {
if (!isJsonRecord(choice)) continue
const messageText = isJsonRecord(choice.message)
? extractTextFromContentParts(choice.message.content, depth + 1)
: null
const deltaText = isJsonRecord(choice.delta)
? extractTextFromContentParts(choice.delta.content, depth + 1)
: null
const text = messageText ?? deltaText ?? extractTextFromContentParts(choice.text, depth + 1)
if (text) return text
}
return null
}
function extractChoicesReasoning(value: unknown, depth: number): string | null {
if (!Array.isArray(value)) return null
for (const choice of value) {
if (!isJsonRecord(choice)) continue
const messageReasoning = isJsonRecord(choice.message)
? extractReasoningFromMessage(choice.message, depth + 1)
: null
const deltaReasoning = isJsonRecord(choice.delta)
? extractReasoningFromMessage(choice.delta, depth + 1)
: null
const reasoning = messageReasoning ?? deltaReasoning
if (reasoning) return reasoning
}
return null
}
function extractReasoningFromMessage(message: JsonRecord, depth: number): string | null {
return compactPreviewText(message.reasoning_content)
?? compactPreviewText(message.thinking)
?? extractReasoningFromContentParts(message.content, depth + 1)
}
function extractOutputText(value: unknown, depth: number): string | null {
if (!Array.isArray(value)) return null
for (const outputItem of value) {
if (!isJsonRecord(outputItem)) continue
const contentText = extractTextFromContentParts(outputItem.content, depth + 1)
?? extractResponseText(outputItem.response, depth + 1)
if (contentText) return contentText
}
return null
}
function extractOutputReasoning(value: unknown, depth: number): string | null {
if (!Array.isArray(value)) return null
for (const outputItem of value) {
if (!isJsonRecord(outputItem)) continue
const reasoning = extractReasoningFromContentParts(outputItem.content, depth + 1)
?? compactPreviewText(outputItem.reasoning_content)
?? compactPreviewText(outputItem.thinking)
?? extractResponseReasoning(outputItem.response, depth + 1)
if (reasoning) return reasoning
}
return null
}
function extractImagePreviewFromCollection(value: unknown, depth: number): string | null {
if (!Array.isArray(value)) return null
for (const item of value) {
if (!isJsonRecord(item)) continue
const preview = extractImagePreviewFromRecord(item, depth + 1)
if (preview) return preview
}
return null
}
function extractImagePreviewFromContentParts(value: unknown, depth: number): string | null {
if (!Array.isArray(value)) return null
for (const part of value) {
if (!isJsonRecord(part)) continue
const preview = extractImagePreviewFromRecord(part, depth + 1)
if (preview) return preview
}
return null
}
function extractImagePreviewFromRecord(value: JsonRecord, depth: number): string | null {
if (depth > 4) return null
const imageUrl = value.image_url
if (typeof imageUrl === 'string' && imageUrl.trim()) {
return compactPreviewText(`图片:${imageUrl}`)
}
if (isJsonRecord(imageUrl)) {
const nestedUrl = compactPreviewText(imageUrl.url)
if (nestedUrl) return `图片:${nestedUrl}`
if (compactPreviewText(imageUrl.b64_json)) {
return '图片:base64'
}
}
const url = compactPreviewText(value.url)
if (url) return `图片:${url}`
if (compactPreviewText(value.b64_json)) {
return '图片:base64'
}
if (value.type === 'image_generation_call' && compactPreviewText(value.result)) {
return '图片:base64'
}
return extractImagePreviewFromCollection(value.data, depth + 1)
?? extractImagePreviewFromCollection(value.images, depth + 1)
?? extractImagePreviewFromContentParts(value.content, depth + 1)
}
function extractGeminiCandidateText(value: unknown, depth: number): string | null {
if (!Array.isArray(value)) return null
for (const candidate of value) {
if (!isJsonRecord(candidate) || !isJsonRecord(candidate.content)) continue
const text = extractTextFromContentParts(candidate.content.parts, depth + 1)
if (text) return text
}
return null
}
function extractReasoningFromContentParts(value: unknown, depth = 0): string | null {
if (depth > 4 || !Array.isArray(value)) return null
const parts = value.flatMap((part) => {
if (!isJsonRecord(part)) return []
const reasoning = compactPreviewText(part.reasoning_content)
?? compactPreviewText(part.thinking)
?? compactPreviewText(part.reasoning)
?? extractReasoningFromContentParts(part.content, depth + 1)
?? extractReasoningFromContentParts(part.parts, depth + 1)
return reasoning ? [reasoning] : []
})
return joinPreviewParts(parts)
}
function extractResponseSummary(responseBody: unknown): string | null {
if (!isJsonRecord(responseBody)) return null
if (Array.isArray(responseBody.data)) {
const embeddingDimensions = responseBody.data
.map(item => isJsonRecord(item) && Array.isArray(item.embedding) ? item.embedding.length : null)
.find((size): size is number => typeof size === 'number')
if (embeddingDimensions != null) return `Embedding 维度:${embeddingDimensions}`
if (responseBody.data.length > 0) return `返回数据:${responseBody.data.length} 条`
}
if (Array.isArray(responseBody.results)) return `Rerank 结果:${responseBody.results.length} 条`
const model = compactPreviewText(responseBody.model)
if (model) return `返回模型:${model}`
return null
}
@@ -1,9 +1,33 @@
import { normalizeApiFormatAlias } from '@/api/endpoints/types/api-format'
import type { ProviderModelMapping } from '@/api/endpoints/types'
import type { TestModelRequest } from '@/api/endpoints/providers'
import {
modelSupportsImageGeneration,
normalizeModelTestStringList,
type ModelTestImageSource,
} from './model-test-capabilities'
export {
formatModelTestDiagnostic,
getOpenAiImageModelTestCapability,
getOpenAiImageModelTestMaxGenerationCount,
isModelTestableApiFormat,
isModelTestableEndpoint,
modelTestKeySupportsEndpoint,
selectPreferredModelTestEndpoint,
} from './model-test-capabilities'
export type {
ModelTestEndpointSource,
ModelTestImageSource,
ModelTestKeySource,
} from './model-test-capabilities'
export {
extractModelTestImagePreviews,
extractModelTestResponsePreview,
} from './model-test-preview'
export type { ModelTestImagePreview } from './model-test-preview'
const DEFAULT_MODEL_TEST_MESSAGE = 'Hello! This is a test message.'
const MODEL_TEST_RESPONSE_PREVIEW_MAX_LENGTH = 160
type ModelTestMappingSource = {
provider_model_name: string
@@ -15,350 +39,20 @@ type ModelTestMappingEndpoint = {
api_format: string
}
type ModelTestEndpointSource = {
api_format: string
is_active?: boolean | null
}
type ModelTestKeySource = {
api_formats?: string[] | null
is_active?: boolean | null
auth_type?: string | null
credential_kind?: string | null
oauth_managed?: boolean | null
}
export type ModelTestMappedModelOption = {
name: string
priority: number
}
const MODEL_TEST_UNSUPPORTED_API_FORMATS = new Set([
'openai:video',
'gemini:video',
'gemini:files',
])
const MODEL_TEST_OAUTH_INHERITS_PROVIDER_FORMATS = new Set([
'claude_code',
'codex',
'chatgpt_web',
'gemini_cli',
'vertex_ai',
'antigravity',
'kiro',
])
const MODEL_TEST_BEARER_INHERITS_PROVIDER_FORMATS = new Set([
'chatgpt_web',
])
const MODEL_TEST_DIAGNOSTIC_LABELS: Record<string, string> = {
pool_account_blocked: '账号已失效,需重新授权',
}
type JsonRecord = Record<string, unknown>
export function isModelTestableApiFormat(apiFormat: string | null | undefined): boolean {
const normalized = normalizeApiFormatAlias(apiFormat ?? '')
return Boolean(normalized) && !MODEL_TEST_UNSUPPORTED_API_FORMATS.has(normalized)
}
export function modelTestKeySupportsEndpoint(
key: ModelTestKeySource,
endpoint: ModelTestEndpointSource,
providerType?: string | null,
): boolean {
if (key.is_active === false) return false
const endpointFormat = normalizeApiFormatAlias(endpoint.api_format)
if (!isModelTestableApiFormat(endpointFormat)) return false
if (modelTestKeyInheritsProviderFormats(key, providerType)) return true
const keyFormats = normalizeStringList(key.api_formats ?? undefined)
if (keyFormats.length === 0) return true
return keyFormats.some(format => normalizeApiFormatAlias(format) === endpointFormat)
}
export function isModelTestableEndpoint(
endpoint: ModelTestEndpointSource,
keys: ModelTestKeySource[],
providerType?: string | null,
): boolean {
return endpoint.is_active !== false
&& isModelTestableApiFormat(endpoint.api_format)
&& keys.some(key => modelTestKeySupportsEndpoint(key, endpoint, providerType))
}
export function formatModelTestDiagnostic(value: string | null | undefined): string {
const normalized = value?.trim()
if (!normalized) return ''
return MODEL_TEST_DIAGNOSTIC_LABELS[normalized] ?? normalized
}
export function extractModelTestResponsePreview(responseBody: unknown): string | null {
const text = extractResponseText(responseBody)
if (text) return text
const reasoning = extractResponseReasoning(responseBody)
if (reasoning) return `推理:${reasoning}`
const summary = extractResponseSummary(responseBody)
if (summary) return summary
return null
}
function normalizeStringList(values: string[] | undefined): string[] {
return (values ?? [])
.map(value => value.trim())
.filter(Boolean)
}
function modelTestKeyInheritsProviderFormats(
key: ModelTestKeySource,
providerType: string | null | undefined,
): boolean {
const normalizedProviderType = providerType?.trim().toLowerCase()
if (!normalizedProviderType) return false
const authType = key.auth_type?.trim().toLowerCase()
const credentialKind = key.credential_kind?.trim().toLowerCase()
const oauthManaged = key.oauth_managed === true
|| credentialKind === 'oauth_session'
|| authType === 'oauth'
if (oauthManaged && MODEL_TEST_OAUTH_INHERITS_PROVIDER_FORMATS.has(normalizedProviderType)) {
return true
}
return authType === 'bearer'
&& MODEL_TEST_BEARER_INHERITS_PROVIDER_FORMATS.has(normalizedProviderType)
}
function isJsonRecord(value: unknown): value is JsonRecord {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
}
function compactPreviewText(value: unknown): string | null {
if (typeof value !== 'string') return null
const normalized = value.replace(/\s+/g, ' ').trim()
if (!normalized) return null
if (normalized.length <= MODEL_TEST_RESPONSE_PREVIEW_MAX_LENGTH) {
return normalized
}
return `${normalized.slice(0, MODEL_TEST_RESPONSE_PREVIEW_MAX_LENGTH - 3)}...`
}
function joinPreviewParts(parts: string[]): string | null {
return compactPreviewText(parts.filter(Boolean).join(' '))
}
function extractTextFromContentParts(value: unknown, depth = 0): string | null {
if (depth > 4) return null
const directText = compactPreviewText(value)
if (directText) return directText
if (!Array.isArray(value)) return null
const parts = value.flatMap((part) => {
if (typeof part === 'string') return [part]
if (!isJsonRecord(part)) return []
const text = compactPreviewText(part.text)
?? compactPreviewText(part.content)
?? extractTextFromContentParts(part.parts, depth + 1)
return text ? [text] : []
})
return joinPreviewParts(parts)
}
function extractResponseText(responseBody: unknown, depth = 0): string | null {
if (depth > 4 || !isJsonRecord(responseBody)) return null
const wrappedText = extractResponseText(responseBody.response, depth + 1)
?? extractResponseText(responseBody.body, depth + 1)
if (wrappedText) return wrappedText
const outputText = compactPreviewText(responseBody.output_text)
if (outputText) return outputText
const topLevelContentText = extractTextFromContentParts(responseBody.content, depth + 1)
if (topLevelContentText) return topLevelContentText
const choicesText = extractChoicesText(responseBody.choices, depth + 1)
if (choicesText) return choicesText
const outputTextParts = extractOutputText(responseBody.output, depth + 1)
if (outputTextParts) return outputTextParts
const candidateText = extractGeminiCandidateText(responseBody.candidates, depth + 1)
if (candidateText) return candidateText
return null
}
function extractResponseReasoning(responseBody: unknown, depth = 0): string | null {
if (depth > 4 || !isJsonRecord(responseBody)) return null
const wrappedReasoning = extractResponseReasoning(responseBody.response, depth + 1)
?? extractResponseReasoning(responseBody.body, depth + 1)
if (wrappedReasoning) return wrappedReasoning
const directReasoning = compactPreviewText(responseBody.reasoning_content)
?? compactPreviewText(responseBody.thinking)
if (directReasoning) return directReasoning
const topLevelReasoning = extractReasoningFromContentParts(responseBody.content, depth + 1)
if (topLevelReasoning) return topLevelReasoning
const choicesReasoning = extractChoicesReasoning(responseBody.choices, depth + 1)
if (choicesReasoning) return choicesReasoning
const outputReasoning = extractOutputReasoning(responseBody.output, depth + 1)
if (outputReasoning) return outputReasoning
return null
}
function extractChoicesText(value: unknown, depth: number): string | null {
if (!Array.isArray(value)) return null
for (const choice of value) {
if (!isJsonRecord(choice)) continue
const messageText = isJsonRecord(choice.message)
? extractTextFromContentParts(choice.message.content, depth + 1)
: null
const deltaText = isJsonRecord(choice.delta)
? extractTextFromContentParts(choice.delta.content, depth + 1)
: null
const text = messageText ?? deltaText ?? extractTextFromContentParts(choice.text, depth + 1)
if (text) return text
}
return null
}
function extractChoicesReasoning(value: unknown, depth: number): string | null {
if (!Array.isArray(value)) return null
for (const choice of value) {
if (!isJsonRecord(choice)) continue
const messageReasoning = isJsonRecord(choice.message)
? extractReasoningFromMessage(choice.message, depth + 1)
: null
const deltaReasoning = isJsonRecord(choice.delta)
? extractReasoningFromMessage(choice.delta, depth + 1)
: null
const reasoning = messageReasoning ?? deltaReasoning
if (reasoning) return reasoning
}
return null
}
function extractReasoningFromMessage(message: JsonRecord, depth: number): string | null {
return compactPreviewText(message.reasoning_content)
?? compactPreviewText(message.thinking)
?? extractReasoningFromContentParts(message.content, depth + 1)
}
function extractOutputText(value: unknown, depth: number): string | null {
if (!Array.isArray(value)) return null
for (const outputItem of value) {
if (!isJsonRecord(outputItem)) continue
const contentText = extractTextFromContentParts(outputItem.content, depth + 1)
?? extractResponseText(outputItem.response, depth + 1)
if (contentText) return contentText
}
return null
}
function extractOutputReasoning(value: unknown, depth: number): string | null {
if (!Array.isArray(value)) return null
for (const outputItem of value) {
if (!isJsonRecord(outputItem)) continue
const reasoning = extractReasoningFromContentParts(outputItem.content, depth + 1)
?? compactPreviewText(outputItem.reasoning_content)
?? compactPreviewText(outputItem.thinking)
?? extractResponseReasoning(outputItem.response, depth + 1)
if (reasoning) return reasoning
}
return null
}
function extractGeminiCandidateText(value: unknown, depth: number): string | null {
if (!Array.isArray(value)) return null
for (const candidate of value) {
if (!isJsonRecord(candidate) || !isJsonRecord(candidate.content)) continue
const text = extractTextFromContentParts(candidate.content.parts, depth + 1)
if (text) return text
}
return null
}
function extractReasoningFromContentParts(value: unknown, depth = 0): string | null {
if (depth > 4 || !Array.isArray(value)) return null
const parts = value.flatMap((part) => {
if (!isJsonRecord(part)) return []
const reasoning = compactPreviewText(part.reasoning_content)
?? compactPreviewText(part.thinking)
?? compactPreviewText(part.reasoning)
?? extractReasoningFromContentParts(part.content, depth + 1)
?? extractReasoningFromContentParts(part.parts, depth + 1)
return reasoning ? [reasoning] : []
})
return joinPreviewParts(parts)
}
function extractResponseSummary(responseBody: unknown): string | null {
if (!isJsonRecord(responseBody)) return null
if (Array.isArray(responseBody.data)) {
const embeddingDimensions = responseBody.data
.map(item => isJsonRecord(item) && Array.isArray(item.embedding) ? item.embedding.length : null)
.find((size): size is number => typeof size === 'number')
if (embeddingDimensions != null) return `Embedding 维度:${embeddingDimensions}`
if (responseBody.data.length > 0) return `返回数据:${responseBody.data.length} 条`
}
if (Array.isArray(responseBody.results)) return `Rerank 结果:${responseBody.results.length} 条`
const model = compactPreviewText(responseBody.model)
if (model) return `返回模型:${model}`
return null
}
function mappingApiFormatMatches(mapping: ProviderModelMapping, endpoint: ModelTestMappingEndpoint): boolean {
const apiFormats = normalizeStringList(mapping.api_formats)
const apiFormats = normalizeModelTestStringList(mapping.api_formats)
if (apiFormats.length === 0) return true
const endpointFormat = normalizeApiFormatAlias(endpoint.api_format)
return apiFormats.some(format => normalizeApiFormatAlias(format) === endpointFormat)
}
function mappingEndpointMatches(mapping: ProviderModelMapping, endpoint: ModelTestMappingEndpoint): boolean {
const endpointIds = normalizeStringList(mapping.endpoint_ids)
const endpointIds = normalizeModelTestStringList(mapping.endpoint_ids)
if (endpointIds.length === 0) return true
return endpointIds.includes(endpoint.id)
}
@@ -450,7 +144,11 @@ export function buildExactModelMappingTestRequest(
}
}
export function buildDefaultModelTestRequestBody(modelName: string, apiFormat?: string | null): string {
export function buildDefaultModelTestRequestBody(
modelName: string,
apiFormat?: string | null,
model?: ModelTestImageSource | null,
): string {
const normalizedApiFormat = normalizeApiFormatAlias(apiFormat ?? '')
if (normalizedApiFormat.endsWith(':embedding')) {
@@ -485,6 +183,24 @@ export function buildDefaultModelTestRequestBody(modelName: string, apiFormat?:
}, null, 2)
}
if (normalizedApiFormat === 'openai:responses' && modelSupportsImageGeneration(model)) {
return JSON.stringify({
model: modelName,
input: DEFAULT_MODEL_TEST_MESSAGE,
tools: [
{
type: 'image_generation',
size: '1024x1024',
output_format: 'png',
},
],
tool_choice: {
type: 'image_generation',
},
stream: true,
}, null, 2)
}
return JSON.stringify({
model: modelName,
messages: [
@@ -557,4 +273,4 @@ export function parseModelTestRequestHeadersDraft(
emptyError: null,
invalidTypeError: '测试请求头必须是 JSON 对象',
})
}
}