mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 09:20:22 +08:00
Merge remote-tracking branch 'origin/pr-480'
This commit is contained in:
@@ -59,6 +59,7 @@ export interface Model {
|
|||||||
global_model_display_name?: string
|
global_model_display_name?: string
|
||||||
// 有效配置(合并 Model 和 GlobalModel 的 config)
|
// 有效配置(合并 Model 和 GlobalModel 的 config)
|
||||||
effective_config?: Record<string, unknown> | null
|
effective_config?: Record<string, unknown> | null
|
||||||
|
model_test_capabilities?: ModelTestCapabilities | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ModelCreate {
|
export interface ModelCreate {
|
||||||
@@ -102,6 +103,17 @@ export interface ModelCapabilities {
|
|||||||
[key: string]: boolean
|
[key: string]: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface OpenAiImageModelTestCapability {
|
||||||
|
max_generation_count?: number | null
|
||||||
|
supports_generation?: boolean | null
|
||||||
|
supports_edit?: boolean | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ModelTestCapabilities {
|
||||||
|
'openai:image'?: OpenAiImageModelTestCapability | null
|
||||||
|
[apiFormat: string]: OpenAiImageModelTestCapability | Record<string, unknown> | null | undefined
|
||||||
|
}
|
||||||
|
|
||||||
export interface ProviderModelPriceInfo {
|
export interface ProviderModelPriceInfo {
|
||||||
input_price_per_1m?: number | null
|
input_price_per_1m?: number | null
|
||||||
output_price_per_1m?: number | null
|
output_price_per_1m?: number | null
|
||||||
@@ -248,6 +260,7 @@ export interface UpstreamModel {
|
|||||||
owned_by?: string
|
owned_by?: string
|
||||||
display_name?: string
|
display_name?: string
|
||||||
api_formats: string[] // 该模型支持的所有 API 格式(后端保证返回数组)
|
api_formats: string[] // 该模型支持的所有 API 格式(后端保证返回数组)
|
||||||
|
model_test_capabilities?: ModelTestCapabilities | null
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -374,6 +374,7 @@ import {
|
|||||||
isModelTestableEndpoint,
|
isModelTestableEndpoint,
|
||||||
parseModelTestRequestHeadersDraft,
|
parseModelTestRequestHeadersDraft,
|
||||||
parseModelTestRequestBodyDraft,
|
parseModelTestRequestBodyDraft,
|
||||||
|
selectPreferredModelTestEndpoint,
|
||||||
syncModelTestRequestBodyDraft,
|
syncModelTestRequestBodyDraft,
|
||||||
} from './model-test-request'
|
} from './model-test-request'
|
||||||
|
|
||||||
@@ -428,6 +429,7 @@ const deletingGroup = ref<AliasGroup | null>(null)
|
|||||||
const testingMapping = ref<string | null>(null)
|
const testingMapping = ref<string | null>(null)
|
||||||
const pendingMappingKey = ref<string | null>(null)
|
const pendingMappingKey = ref<string | null>(null)
|
||||||
const testingModelName = ref<string | null>(null)
|
const testingModelName = ref<string | null>(null)
|
||||||
|
const testingSourceModel = ref<Model | null>(null)
|
||||||
const preselectedModelId = ref<string | null>(null)
|
const preselectedModelId = ref<string | null>(null)
|
||||||
const selectedTestEndpoint = ref<ProviderEndpoint | null>(null)
|
const selectedTestEndpoint = ref<ProviderEndpoint | null>(null)
|
||||||
const testRequestHeadersDraft = ref('')
|
const testRequestHeadersDraft = ref('')
|
||||||
@@ -715,6 +717,7 @@ function handleTestDialogClose() {
|
|||||||
modelTest.resetState()
|
modelTest.resetState()
|
||||||
pendingMappingKey.value = null
|
pendingMappingKey.value = null
|
||||||
testingModelName.value = null
|
testingModelName.value = null
|
||||||
|
testingSourceModel.value = null
|
||||||
testingMapping.value = null
|
testingMapping.value = null
|
||||||
selectedTestEndpoint.value = null
|
selectedTestEndpoint.value = null
|
||||||
mappingTestEndpoints.value = null
|
mappingTestEndpoints.value = null
|
||||||
@@ -737,8 +740,25 @@ function handleSelectTestEndpoint(endpointId: string) {
|
|||||||
syncMappingTestRequestBody()
|
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
|
const endpoints = endpointsOverride ?? activeEndpoints.value
|
||||||
if (endpoints.length === 0) {
|
if (endpoints.length === 0) {
|
||||||
showError('暂无可用于测试的活跃端点')
|
showError('暂无可用于测试的活跃端点')
|
||||||
@@ -749,8 +769,12 @@ function runMappingTest(testingKey: string, modelName: string, endpointsOverride
|
|||||||
modelTest.dialogOpen.value = true
|
modelTest.dialogOpen.value = true
|
||||||
testingMapping.value = null
|
testingMapping.value = null
|
||||||
testingModelName.value = modelName
|
testingModelName.value = modelName
|
||||||
|
testingSourceModel.value = sourceModel ?? findMappingTestModel(modelName)
|
||||||
mappingTestEndpoints.value = endpointsOverride ?? null
|
mappingTestEndpoints.value = endpointsOverride ?? null
|
||||||
selectedTestEndpoint.value = endpoints[0] ?? null
|
selectedTestEndpoint.value = selectPreferredModelTestEndpoint(
|
||||||
|
testingSourceModel.value,
|
||||||
|
endpoints,
|
||||||
|
)
|
||||||
testRequestHeadersResetValue.value = buildDefaultModelTestRequestHeaders()
|
testRequestHeadersResetValue.value = buildDefaultModelTestRequestHeaders()
|
||||||
testRequestHeadersDraft.value = testRequestHeadersResetValue.value
|
testRequestHeadersDraft.value = testRequestHeadersResetValue.value
|
||||||
resetMappingTestRequestBody()
|
resetMappingTestRequestBody()
|
||||||
@@ -762,6 +786,7 @@ function resetMappingTestRequestBody() {
|
|||||||
testRequestBodyResetValue.value = buildDefaultModelTestRequestBody(
|
testRequestBodyResetValue.value = buildDefaultModelTestRequestBody(
|
||||||
testingModelName.value,
|
testingModelName.value,
|
||||||
selectedTestEndpoint.value?.api_format,
|
selectedTestEndpoint.value?.api_format,
|
||||||
|
testingSourceModel.value,
|
||||||
)
|
)
|
||||||
testRequestBodyDraft.value = testRequestBodyResetValue.value
|
testRequestBodyDraft.value = testRequestBodyResetValue.value
|
||||||
}
|
}
|
||||||
@@ -772,6 +797,7 @@ function syncMappingTestRequestBody() {
|
|||||||
const nextResetValue = buildDefaultModelTestRequestBody(
|
const nextResetValue = buildDefaultModelTestRequestBody(
|
||||||
testingModelName.value,
|
testingModelName.value,
|
||||||
selectedTestEndpoint.value?.api_format,
|
selectedTestEndpoint.value?.api_format,
|
||||||
|
testingSourceModel.value,
|
||||||
)
|
)
|
||||||
const next = syncModelTestRequestBodyDraft(
|
const next = syncModelTestRequestBodyDraft(
|
||||||
testRequestBodyDraft.value,
|
testRequestBodyDraft.value,
|
||||||
@@ -839,7 +865,7 @@ function scopedMappingEndpoints(item: CombinedMapping): ProviderEndpoint[] {
|
|||||||
|
|
||||||
// 测试精确映射
|
// 测试精确映射
|
||||||
function testMapping(item: CombinedMapping, mapping: MappingItem) {
|
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) }}
|
{{ attempt.key_name || maskKey(attempt.key_id) }}
|
||||||
</div>
|
</div>
|
||||||
<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"
|
class="mt-1 break-all text-muted-foreground"
|
||||||
>
|
>
|
||||||
{{ attemptDetail(attempt) }}
|
{{ attemptDetail(attempt) }}
|
||||||
@@ -519,6 +539,33 @@
|
|||||||
</td>
|
</td>
|
||||||
<td class="px-3 py-2 text-muted-foreground">
|
<td class="px-3 py-2 text-muted-foreground">
|
||||||
<div
|
<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"
|
class="line-clamp-2 break-all"
|
||||||
:title="attemptDetail(attempt)"
|
:title="attemptDetail(attempt)"
|
||||||
>
|
>
|
||||||
@@ -641,6 +688,36 @@
|
|||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="response-body">
|
<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
|
<JsonContent
|
||||||
:data="selectedInspectionAttempt.response_body"
|
:data="selectedInspectionAttempt.response_body"
|
||||||
view-mode="formatted"
|
view-mode="formatted"
|
||||||
@@ -673,6 +750,39 @@
|
|||||||
</Button>
|
</Button>
|
||||||
</template>
|
</template>
|
||||||
</Dialog>
|
</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>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<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 JsonContent from '@/features/usage/components/RequestDetailDrawer/JsonContent.vue'
|
||||||
import { useClipboard } from '@/composables/useClipboard'
|
import { useClipboard } from '@/composables/useClipboard'
|
||||||
import { useDarkMode } from '@/composables/useDarkMode'
|
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 = {
|
type TestEndpointOption = {
|
||||||
id: string
|
id: string
|
||||||
@@ -816,6 +931,7 @@ const inspectionTab = ref<'request-headers' | 'request-body' | 'response-headers
|
|||||||
const selectedInspectionKey = ref<string | null>(null)
|
const selectedInspectionKey = ref<string | null>(null)
|
||||||
const inspectionExpandDepth = ref(0)
|
const inspectionExpandDepth = ref(0)
|
||||||
const inspectionCopiedStates = ref<Record<string, boolean>>({})
|
const inspectionCopiedStates = ref<Record<string, boolean>>({})
|
||||||
|
const activeImagePreview = ref<ModelTestImagePreview | null>(null)
|
||||||
|
|
||||||
watch(() => props.result, () => {
|
watch(() => props.result, () => {
|
||||||
showAllAttempts.value = false
|
showAllAttempts.value = false
|
||||||
@@ -1129,6 +1245,12 @@ const selectedInspectionAttempt = computed(() => {
|
|||||||
return inspectableAttempts.value[0] ?? resultAttempts.value[0] ?? null
|
return inspectableAttempts.value[0] ?? resultAttempts.value[0] ?? null
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const selectedInspectionImagePreviews = computed(() => (
|
||||||
|
selectedInspectionAttempt.value
|
||||||
|
? extractModelTestImagePreviews(selectedInspectionAttempt.value.response_body)
|
||||||
|
: []
|
||||||
|
))
|
||||||
|
|
||||||
const resultWinningTitle = computed(() => {
|
const resultWinningTitle = computed(() => {
|
||||||
const summary = resultSummary.value
|
const summary = resultSummary.value
|
||||||
const keyName = summary.winning_key_name || summary.winning_key_id
|
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 === 'codex') return 'Codex OAuth'
|
||||||
if (lowered === 'antigravity') return 'Antigravity OAuth'
|
if (lowered === 'antigravity') return 'Antigravity OAuth'
|
||||||
if (lowered === 'kiro') return 'Kiro OAuth'
|
if (lowered === 'kiro') return 'Kiro OAuth'
|
||||||
|
if (lowered === 'grok') return 'Grok OAuth'
|
||||||
return authType
|
return authType
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1295,6 +1418,14 @@ function attemptDetail(attempt: TestAttemptDetail): string {
|
|||||||
return '-'
|
return '-'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function attemptImagePreviews(attempt: TestAttemptDetail): ModelTestImagePreview[] {
|
||||||
|
return extractModelTestImagePreviews(attempt.response_body)
|
||||||
|
}
|
||||||
|
|
||||||
|
function openImagePreview(preview: ModelTestImagePreview) {
|
||||||
|
activeImagePreview.value = preview
|
||||||
|
}
|
||||||
|
|
||||||
function inspectionKey(attempt: TestAttemptDetail): string {
|
function inspectionKey(attempt: TestAttemptDetail): string {
|
||||||
return `${attempt.candidate_index}:${attempt.retry_index ?? 0}:${attempt.key_id}`
|
return `${attempt.candidate_index}:${attempt.retry_index ?? 0}:${attempt.key_id}`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -272,6 +272,7 @@ import {
|
|||||||
normalizeModelTestMappedModelSelection,
|
normalizeModelTestMappedModelSelection,
|
||||||
parseModelTestRequestHeadersDraft,
|
parseModelTestRequestHeadersDraft,
|
||||||
parseModelTestRequestBodyDraft,
|
parseModelTestRequestBodyDraft,
|
||||||
|
selectPreferredModelTestEndpoint,
|
||||||
syncModelTestRequestBodyDraft,
|
syncModelTestRequestBodyDraft,
|
||||||
} from './model-test-request'
|
} from './model-test-request'
|
||||||
|
|
||||||
@@ -586,7 +587,7 @@ async function testModelConnection(model: Model) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pendingTestModel.value = model
|
pendingTestModel.value = model
|
||||||
selectedTestEndpoint.value = activeEndpoints.value[0] ?? null
|
selectedTestEndpoint.value = selectPreferredModelTestEndpoint(model, activeEndpoints.value)
|
||||||
const requestedModelName = getModelTestRequestedModelName(model)
|
const requestedModelName = getModelTestRequestedModelName(model)
|
||||||
selectedTestMappedModelName.value = null
|
selectedTestMappedModelName.value = null
|
||||||
testRequestHeadersResetValue.value = buildDefaultModelTestRequestHeaders()
|
testRequestHeadersResetValue.value = buildDefaultModelTestRequestHeaders()
|
||||||
@@ -594,6 +595,7 @@ async function testModelConnection(model: Model) {
|
|||||||
testRequestBodyResetValue.value = buildDefaultModelTestRequestBody(
|
testRequestBodyResetValue.value = buildDefaultModelTestRequestBody(
|
||||||
requestedModelName,
|
requestedModelName,
|
||||||
selectedTestEndpoint.value?.api_format,
|
selectedTestEndpoint.value?.api_format,
|
||||||
|
model,
|
||||||
)
|
)
|
||||||
testRequestBodyDraft.value = testRequestBodyResetValue.value
|
testRequestBodyDraft.value = testRequestBodyResetValue.value
|
||||||
modelTest.testResult.value = null
|
modelTest.testResult.value = null
|
||||||
@@ -619,7 +621,11 @@ function syncTestRequestBodyModel() {
|
|||||||
if (!modelName) return
|
if (!modelName) return
|
||||||
|
|
||||||
const resetDraft = testRequestBodyResetValue.value
|
const resetDraft = testRequestBodyResetValue.value
|
||||||
|| buildDefaultModelTestRequestBody(modelName, selectedTestEndpoint.value?.api_format)
|
|| buildDefaultModelTestRequestBody(
|
||||||
|
modelName,
|
||||||
|
selectedTestEndpoint.value?.api_format,
|
||||||
|
pendingTestModel.value,
|
||||||
|
)
|
||||||
const next = syncModelTestRequestBodyDraft(
|
const next = syncModelTestRequestBodyDraft(
|
||||||
testRequestBodyDraft.value,
|
testRequestBodyDraft.value,
|
||||||
testRequestBodyResetValue.value,
|
testRequestBodyResetValue.value,
|
||||||
@@ -637,6 +643,7 @@ function resetTestRequestBodyForSelectedEndpoint() {
|
|||||||
const nextResetValue = buildDefaultModelTestRequestBody(
|
const nextResetValue = buildDefaultModelTestRequestBody(
|
||||||
modelName,
|
modelName,
|
||||||
selectedTestEndpoint.value?.api_format,
|
selectedTestEndpoint.value?.api_format,
|
||||||
|
pendingTestModel.value,
|
||||||
)
|
)
|
||||||
const next = syncModelTestRequestBodyDraft(
|
const next = syncModelTestRequestBodyDraft(
|
||||||
testRequestBodyDraft.value,
|
testRequestBodyDraft.value,
|
||||||
|
|||||||
@@ -3,12 +3,15 @@ import { describe, expect, it } from 'vitest'
|
|||||||
import {
|
import {
|
||||||
buildDefaultModelTestRequestBody,
|
buildDefaultModelTestRequestBody,
|
||||||
buildExactModelMappingTestRequest,
|
buildExactModelMappingTestRequest,
|
||||||
|
extractModelTestImagePreviews,
|
||||||
extractModelTestResponsePreview,
|
extractModelTestResponsePreview,
|
||||||
formatModelTestDiagnostic,
|
formatModelTestDiagnostic,
|
||||||
|
getOpenAiImageModelTestMaxGenerationCount,
|
||||||
isModelTestableEndpoint,
|
isModelTestableEndpoint,
|
||||||
isModelTestableApiFormat,
|
isModelTestableApiFormat,
|
||||||
listModelTestMappedModelOptions,
|
listModelTestMappedModelOptions,
|
||||||
normalizeModelTestMappedModelSelection,
|
normalizeModelTestMappedModelSelection,
|
||||||
|
selectPreferredModelTestEndpoint,
|
||||||
setModelTestRequestBodyModel,
|
setModelTestRequestBodyModel,
|
||||||
syncModelTestRequestBodyDraft,
|
syncModelTestRequestBodyDraft,
|
||||||
} from '../model-test-request'
|
} from '../model-test-request'
|
||||||
@@ -55,6 +58,41 @@ describe('buildDefaultModelTestRequestBody', () => {
|
|||||||
expect(body.input).toBeUndefined()
|
expect(body.input).toBeUndefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('uses prompt payloads for openai image api formats', () => {
|
||||||
|
const body = JSON.parse(buildDefaultModelTestRequestBody('gpt-image-2', 'openai:image'))
|
||||||
|
|
||||||
|
expect(body).toEqual({
|
||||||
|
model: 'gpt-image-2',
|
||||||
|
prompt: 'Hello! This is a test message.',
|
||||||
|
n: 1,
|
||||||
|
size: '1024x1024',
|
||||||
|
stream: true,
|
||||||
|
})
|
||||||
|
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', () => {
|
it('lists endpoint-scoped provider model mappings in test selection order', () => {
|
||||||
const options = listModelTestMappedModelOptions({
|
const options = listModelTestMappedModelOptions({
|
||||||
provider_model_name: 'claude-opus-4-6',
|
provider_model_name: 'claude-opus-4-6',
|
||||||
@@ -204,6 +242,7 @@ describe('isModelTestableApiFormat', () => {
|
|||||||
'openai:responses',
|
'openai:responses',
|
||||||
'claude:messages',
|
'claude:messages',
|
||||||
'gemini:generate_content',
|
'gemini:generate_content',
|
||||||
|
'openai:image',
|
||||||
'openai:embedding',
|
'openai:embedding',
|
||||||
'jina:rerank',
|
'jina:rerank',
|
||||||
])('allows synchronous model-test endpoint formats: %s', (apiFormat) => {
|
])('allows synchronous model-test endpoint formats: %s', (apiFormat) => {
|
||||||
@@ -211,6 +250,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', () => {
|
describe('isModelTestableEndpoint', () => {
|
||||||
it('requires at least one active key compatible with the endpoint format', () => {
|
it('requires at least one active key compatible with the endpoint format', () => {
|
||||||
const keys = [
|
const keys = [
|
||||||
@@ -341,6 +449,95 @@ describe('extractModelTestResponsePreview', () => {
|
|||||||
})).toBe('Rerank 结果:2 条')
|
})).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', () => {
|
it('falls back to response model when no text payload exists', () => {
|
||||||
expect(extractModelTestResponsePreview({
|
expect(extractModelTestResponsePreview({
|
||||||
model: 'glm-4.5-air',
|
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 { normalizeApiFormatAlias } from '@/api/endpoints/types/api-format'
|
||||||
import type { ProviderModelMapping } from '@/api/endpoints/types'
|
import type { ProviderModelMapping } from '@/api/endpoints/types'
|
||||||
import type { TestModelRequest } from '@/api/endpoints/providers'
|
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 DEFAULT_MODEL_TEST_MESSAGE = 'Hello! This is a test message.'
|
||||||
const MODEL_TEST_RESPONSE_PREVIEW_MAX_LENGTH = 160
|
|
||||||
|
|
||||||
type ModelTestMappingSource = {
|
type ModelTestMappingSource = {
|
||||||
provider_model_name: string
|
provider_model_name: string
|
||||||
@@ -15,308 +39,20 @@ type ModelTestMappingEndpoint = {
|
|||||||
api_format: string
|
api_format: string
|
||||||
}
|
}
|
||||||
|
|
||||||
type ModelTestEndpointSource = {
|
|
||||||
api_format: string
|
|
||||||
is_active?: boolean | null
|
|
||||||
}
|
|
||||||
|
|
||||||
type ModelTestKeySource = {
|
|
||||||
api_formats?: string[] | null
|
|
||||||
is_active?: boolean | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export type ModelTestMappedModelOption = {
|
export type ModelTestMappedModelOption = {
|
||||||
name: string
|
name: string
|
||||||
priority: number
|
priority: number
|
||||||
}
|
}
|
||||||
|
|
||||||
const MODEL_TEST_UNSUPPORTED_API_FORMATS = new Set([
|
|
||||||
'openai:video',
|
|
||||||
'gemini:video',
|
|
||||||
'gemini:files',
|
|
||||||
])
|
|
||||||
|
|
||||||
const MODEL_TEST_DIAGNOSTIC_LABELS: Record<string, string> = {
|
|
||||||
pool_account_blocked: '账号已失效,需重新授权',
|
|
||||||
}
|
|
||||||
|
|
||||||
type JsonRecord = Record<string, unknown>
|
|
||||||
|
|
||||||
export function isModelTestableApiFormat(apiFormat: string | null | undefined): boolean {
|
|
||||||
const normalized = normalizeApiFormatAlias(apiFormat ?? '')
|
|
||||||
return Boolean(normalized) && !MODEL_TEST_UNSUPPORTED_API_FORMATS.has(normalized)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function modelTestKeySupportsEndpoint(
|
|
||||||
key: ModelTestKeySource,
|
|
||||||
endpoint: ModelTestEndpointSource,
|
|
||||||
): boolean {
|
|
||||||
if (key.is_active === false) return false
|
|
||||||
|
|
||||||
const endpointFormat = normalizeApiFormatAlias(endpoint.api_format)
|
|
||||||
if (!isModelTestableApiFormat(endpointFormat)) return false
|
|
||||||
|
|
||||||
const keyFormats = normalizeStringList(key.api_formats ?? undefined)
|
|
||||||
if (keyFormats.length === 0) return true
|
|
||||||
|
|
||||||
return keyFormats.some(format => normalizeApiFormatAlias(format) === endpointFormat)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isModelTestableEndpoint(
|
|
||||||
endpoint: ModelTestEndpointSource,
|
|
||||||
keys: ModelTestKeySource[],
|
|
||||||
): boolean {
|
|
||||||
return endpoint.is_active !== false
|
|
||||||
&& isModelTestableApiFormat(endpoint.api_format)
|
|
||||||
&& keys.some(key => modelTestKeySupportsEndpoint(key, endpoint))
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatModelTestDiagnostic(value: string | null | undefined): string {
|
|
||||||
const normalized = value?.trim()
|
|
||||||
if (!normalized) return ''
|
|
||||||
return MODEL_TEST_DIAGNOSTIC_LABELS[normalized] ?? normalized
|
|
||||||
}
|
|
||||||
|
|
||||||
export function extractModelTestResponsePreview(responseBody: unknown): string | null {
|
|
||||||
const text = extractResponseText(responseBody)
|
|
||||||
if (text) return text
|
|
||||||
|
|
||||||
const reasoning = extractResponseReasoning(responseBody)
|
|
||||||
if (reasoning) return `推理:${reasoning}`
|
|
||||||
|
|
||||||
const summary = extractResponseSummary(responseBody)
|
|
||||||
if (summary) return summary
|
|
||||||
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeStringList(values: string[] | undefined): string[] {
|
|
||||||
return (values ?? [])
|
|
||||||
.map(value => value.trim())
|
|
||||||
.filter(Boolean)
|
|
||||||
}
|
|
||||||
|
|
||||||
function isJsonRecord(value: unknown): value is JsonRecord {
|
|
||||||
return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
|
|
||||||
}
|
|
||||||
|
|
||||||
function compactPreviewText(value: unknown): string | null {
|
|
||||||
if (typeof value !== 'string') return null
|
|
||||||
|
|
||||||
const normalized = value.replace(/\s+/g, ' ').trim()
|
|
||||||
if (!normalized) return null
|
|
||||||
|
|
||||||
if (normalized.length <= MODEL_TEST_RESPONSE_PREVIEW_MAX_LENGTH) {
|
|
||||||
return normalized
|
|
||||||
}
|
|
||||||
return `${normalized.slice(0, MODEL_TEST_RESPONSE_PREVIEW_MAX_LENGTH - 3)}...`
|
|
||||||
}
|
|
||||||
|
|
||||||
function joinPreviewParts(parts: string[]): string | null {
|
|
||||||
return compactPreviewText(parts.filter(Boolean).join(' '))
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractTextFromContentParts(value: unknown, depth = 0): string | null {
|
|
||||||
if (depth > 4) return null
|
|
||||||
|
|
||||||
const directText = compactPreviewText(value)
|
|
||||||
if (directText) return directText
|
|
||||||
|
|
||||||
if (!Array.isArray(value)) return null
|
|
||||||
|
|
||||||
const parts = value.flatMap((part) => {
|
|
||||||
if (typeof part === 'string') return [part]
|
|
||||||
if (!isJsonRecord(part)) return []
|
|
||||||
|
|
||||||
const text = compactPreviewText(part.text)
|
|
||||||
?? compactPreviewText(part.content)
|
|
||||||
?? extractTextFromContentParts(part.parts, depth + 1)
|
|
||||||
return text ? [text] : []
|
|
||||||
})
|
|
||||||
|
|
||||||
return joinPreviewParts(parts)
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractResponseText(responseBody: unknown, depth = 0): string | null {
|
|
||||||
if (depth > 4 || !isJsonRecord(responseBody)) return null
|
|
||||||
|
|
||||||
const wrappedText = extractResponseText(responseBody.response, depth + 1)
|
|
||||||
?? extractResponseText(responseBody.body, depth + 1)
|
|
||||||
if (wrappedText) return wrappedText
|
|
||||||
|
|
||||||
const outputText = compactPreviewText(responseBody.output_text)
|
|
||||||
if (outputText) return outputText
|
|
||||||
|
|
||||||
const topLevelContentText = extractTextFromContentParts(responseBody.content, depth + 1)
|
|
||||||
if (topLevelContentText) return topLevelContentText
|
|
||||||
|
|
||||||
const choicesText = extractChoicesText(responseBody.choices, depth + 1)
|
|
||||||
if (choicesText) return choicesText
|
|
||||||
|
|
||||||
const outputTextParts = extractOutputText(responseBody.output, depth + 1)
|
|
||||||
if (outputTextParts) return outputTextParts
|
|
||||||
|
|
||||||
const candidateText = extractGeminiCandidateText(responseBody.candidates, depth + 1)
|
|
||||||
if (candidateText) return candidateText
|
|
||||||
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractResponseReasoning(responseBody: unknown, depth = 0): string | null {
|
|
||||||
if (depth > 4 || !isJsonRecord(responseBody)) return null
|
|
||||||
|
|
||||||
const wrappedReasoning = extractResponseReasoning(responseBody.response, depth + 1)
|
|
||||||
?? extractResponseReasoning(responseBody.body, depth + 1)
|
|
||||||
if (wrappedReasoning) return wrappedReasoning
|
|
||||||
|
|
||||||
const directReasoning = compactPreviewText(responseBody.reasoning_content)
|
|
||||||
?? compactPreviewText(responseBody.thinking)
|
|
||||||
if (directReasoning) return directReasoning
|
|
||||||
|
|
||||||
const topLevelReasoning = extractReasoningFromContentParts(responseBody.content, depth + 1)
|
|
||||||
if (topLevelReasoning) return topLevelReasoning
|
|
||||||
|
|
||||||
const choicesReasoning = extractChoicesReasoning(responseBody.choices, depth + 1)
|
|
||||||
if (choicesReasoning) return choicesReasoning
|
|
||||||
|
|
||||||
const outputReasoning = extractOutputReasoning(responseBody.output, depth + 1)
|
|
||||||
if (outputReasoning) return outputReasoning
|
|
||||||
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractChoicesText(value: unknown, depth: number): string | null {
|
|
||||||
if (!Array.isArray(value)) return null
|
|
||||||
|
|
||||||
for (const choice of value) {
|
|
||||||
if (!isJsonRecord(choice)) continue
|
|
||||||
|
|
||||||
const messageText = isJsonRecord(choice.message)
|
|
||||||
? extractTextFromContentParts(choice.message.content, depth + 1)
|
|
||||||
: null
|
|
||||||
const deltaText = isJsonRecord(choice.delta)
|
|
||||||
? extractTextFromContentParts(choice.delta.content, depth + 1)
|
|
||||||
: null
|
|
||||||
const text = messageText ?? deltaText ?? extractTextFromContentParts(choice.text, depth + 1)
|
|
||||||
if (text) return text
|
|
||||||
}
|
|
||||||
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractChoicesReasoning(value: unknown, depth: number): string | null {
|
|
||||||
if (!Array.isArray(value)) return null
|
|
||||||
|
|
||||||
for (const choice of value) {
|
|
||||||
if (!isJsonRecord(choice)) continue
|
|
||||||
|
|
||||||
const messageReasoning = isJsonRecord(choice.message)
|
|
||||||
? extractReasoningFromMessage(choice.message, depth + 1)
|
|
||||||
: null
|
|
||||||
const deltaReasoning = isJsonRecord(choice.delta)
|
|
||||||
? extractReasoningFromMessage(choice.delta, depth + 1)
|
|
||||||
: null
|
|
||||||
const reasoning = messageReasoning ?? deltaReasoning
|
|
||||||
if (reasoning) return reasoning
|
|
||||||
}
|
|
||||||
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractReasoningFromMessage(message: JsonRecord, depth: number): string | null {
|
|
||||||
return compactPreviewText(message.reasoning_content)
|
|
||||||
?? compactPreviewText(message.thinking)
|
|
||||||
?? extractReasoningFromContentParts(message.content, depth + 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractOutputText(value: unknown, depth: number): string | null {
|
|
||||||
if (!Array.isArray(value)) return null
|
|
||||||
|
|
||||||
for (const outputItem of value) {
|
|
||||||
if (!isJsonRecord(outputItem)) continue
|
|
||||||
|
|
||||||
const contentText = extractTextFromContentParts(outputItem.content, depth + 1)
|
|
||||||
?? extractResponseText(outputItem.response, depth + 1)
|
|
||||||
if (contentText) return contentText
|
|
||||||
}
|
|
||||||
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractOutputReasoning(value: unknown, depth: number): string | null {
|
|
||||||
if (!Array.isArray(value)) return null
|
|
||||||
|
|
||||||
for (const outputItem of value) {
|
|
||||||
if (!isJsonRecord(outputItem)) continue
|
|
||||||
|
|
||||||
const reasoning = extractReasoningFromContentParts(outputItem.content, depth + 1)
|
|
||||||
?? compactPreviewText(outputItem.reasoning_content)
|
|
||||||
?? compactPreviewText(outputItem.thinking)
|
|
||||||
?? extractResponseReasoning(outputItem.response, depth + 1)
|
|
||||||
if (reasoning) return reasoning
|
|
||||||
}
|
|
||||||
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractGeminiCandidateText(value: unknown, depth: number): string | null {
|
|
||||||
if (!Array.isArray(value)) return null
|
|
||||||
|
|
||||||
for (const candidate of value) {
|
|
||||||
if (!isJsonRecord(candidate) || !isJsonRecord(candidate.content)) continue
|
|
||||||
|
|
||||||
const text = extractTextFromContentParts(candidate.content.parts, depth + 1)
|
|
||||||
if (text) return text
|
|
||||||
}
|
|
||||||
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractReasoningFromContentParts(value: unknown, depth = 0): string | null {
|
|
||||||
if (depth > 4 || !Array.isArray(value)) return null
|
|
||||||
|
|
||||||
const parts = value.flatMap((part) => {
|
|
||||||
if (!isJsonRecord(part)) return []
|
|
||||||
|
|
||||||
const reasoning = compactPreviewText(part.reasoning_content)
|
|
||||||
?? compactPreviewText(part.thinking)
|
|
||||||
?? compactPreviewText(part.reasoning)
|
|
||||||
?? extractReasoningFromContentParts(part.content, depth + 1)
|
|
||||||
?? extractReasoningFromContentParts(part.parts, depth + 1)
|
|
||||||
return reasoning ? [reasoning] : []
|
|
||||||
})
|
|
||||||
|
|
||||||
return joinPreviewParts(parts)
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractResponseSummary(responseBody: unknown): string | null {
|
|
||||||
if (!isJsonRecord(responseBody)) return null
|
|
||||||
|
|
||||||
if (Array.isArray(responseBody.data)) {
|
|
||||||
const embeddingDimensions = responseBody.data
|
|
||||||
.map(item => isJsonRecord(item) && Array.isArray(item.embedding) ? item.embedding.length : null)
|
|
||||||
.find((size): size is number => typeof size === 'number')
|
|
||||||
if (embeddingDimensions != null) return `Embedding 维度:${embeddingDimensions}`
|
|
||||||
if (responseBody.data.length > 0) return `返回数据:${responseBody.data.length} 条`
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Array.isArray(responseBody.results)) return `Rerank 结果:${responseBody.results.length} 条`
|
|
||||||
|
|
||||||
const model = compactPreviewText(responseBody.model)
|
|
||||||
if (model) return `返回模型:${model}`
|
|
||||||
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
function mappingApiFormatMatches(mapping: ProviderModelMapping, endpoint: ModelTestMappingEndpoint): boolean {
|
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
|
if (apiFormats.length === 0) return true
|
||||||
const endpointFormat = normalizeApiFormatAlias(endpoint.api_format)
|
const endpointFormat = normalizeApiFormatAlias(endpoint.api_format)
|
||||||
return apiFormats.some(format => normalizeApiFormatAlias(format) === endpointFormat)
|
return apiFormats.some(format => normalizeApiFormatAlias(format) === endpointFormat)
|
||||||
}
|
}
|
||||||
|
|
||||||
function mappingEndpointMatches(mapping: ProviderModelMapping, endpoint: ModelTestMappingEndpoint): boolean {
|
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
|
if (endpointIds.length === 0) return true
|
||||||
return endpointIds.includes(endpoint.id)
|
return endpointIds.includes(endpoint.id)
|
||||||
}
|
}
|
||||||
@@ -408,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 {
|
||||||
if (apiFormat?.trim().toLowerCase().endsWith(':embedding')) {
|
if (apiFormat?.trim().toLowerCase().endsWith(':embedding')) {
|
||||||
return JSON.stringify({
|
return JSON.stringify({
|
||||||
model: modelName,
|
model: modelName,
|
||||||
@@ -431,6 +171,34 @@ export function buildDefaultModelTestRequestBody(modelName: string, apiFormat?:
|
|||||||
}, null, 2)
|
}, null, 2)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (normalizeApiFormatAlias(apiFormat ?? '') === 'openai:image') {
|
||||||
|
return JSON.stringify({
|
||||||
|
model: modelName,
|
||||||
|
prompt: DEFAULT_MODEL_TEST_MESSAGE,
|
||||||
|
n: 1,
|
||||||
|
size: '1024x1024',
|
||||||
|
stream: true,
|
||||||
|
}, null, 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalizeApiFormatAlias(apiFormat ?? '') === '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({
|
return JSON.stringify({
|
||||||
model: modelName,
|
model: modelName,
|
||||||
messages: [
|
messages: [
|
||||||
|
|||||||
Reference in New Issue
Block a user