mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 01:10:23 +08:00
feat(model-test): add image-aware request helpers
This commit is contained in:
@@ -59,6 +59,7 @@ export interface Model {
|
||||
global_model_display_name?: string
|
||||
// 有效配置(合并 Model 和 GlobalModel 的 config)
|
||||
effective_config?: Record<string, unknown> | null
|
||||
model_test_capabilities?: ModelTestCapabilities | null
|
||||
}
|
||||
|
||||
export interface ModelCreate {
|
||||
@@ -102,6 +103,17 @@ export interface ModelCapabilities {
|
||||
[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 {
|
||||
input_price_per_1m?: number | null
|
||||
output_price_per_1m?: number | null
|
||||
@@ -248,6 +260,7 @@ export interface UpstreamModel {
|
||||
owned_by?: string
|
||||
display_name?: string
|
||||
api_formats: string[] // 该模型支持的所有 API 格式(后端保证返回数组)
|
||||
model_test_capabilities?: ModelTestCapabilities | null
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,308 +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
|
||||
}
|
||||
|
||||
export type ModelTestMappedModelOption = {
|
||||
name: string
|
||||
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 {
|
||||
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)
|
||||
}
|
||||
@@ -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')) {
|
||||
return JSON.stringify({
|
||||
model: modelName,
|
||||
@@ -431,6 +171,34 @@ export function buildDefaultModelTestRequestBody(modelName: string, apiFormat?:
|
||||
}, 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({
|
||||
model: modelName,
|
||||
messages: [
|
||||
|
||||
Reference in New Issue
Block a user