Merge remote-tracking branch 'upstream/main'

# Conflicts:
#	apps/aether-gateway/src/handlers/admin/provider/endpoints_admin/payloads.rs
#	apps/aether-gateway/src/handlers/admin/provider/endpoints_admin/reads.rs
#	apps/aether-gateway/src/handlers/admin/provider/endpoints_admin/update.rs
#	apps/aether-gateway/src/tests/control/admin/endpoints/routes.rs
#	frontend/src/features/models/components/GlobalModelFormDialog.vue
#	frontend/src/features/providers/components/ProviderModelFormDialog.vue
#	frontend/src/features/providers/components/provider-tabs/__tests__/model-test-request.spec.ts
#	frontend/src/features/providers/components/provider-tabs/model-test-request.ts
This commit is contained in:
ZheFox
2026-05-18 22:02:31 +08:00
102 changed files with 14682 additions and 2875 deletions
@@ -1,9 +1,33 @@
import { normalizeApiFormatAlias } from '@/api/endpoints/types/api-format'
import type { ProviderModelMapping } from '@/api/endpoints/types'
import type { TestModelRequest } from '@/api/endpoints/providers'
import {
modelSupportsImageGeneration,
normalizeModelTestStringList,
type ModelTestImageSource,
} from './model-test-capabilities'
export {
formatModelTestDiagnostic,
getOpenAiImageModelTestCapability,
getOpenAiImageModelTestMaxGenerationCount,
isModelTestableApiFormat,
isModelTestableEndpoint,
modelTestKeySupportsEndpoint,
selectPreferredModelTestEndpoint,
} from './model-test-capabilities'
export type {
ModelTestEndpointSource,
ModelTestImageSource,
ModelTestKeySource,
} from './model-test-capabilities'
export {
extractModelTestImagePreviews,
extractModelTestResponsePreview,
} from './model-test-preview'
export type { ModelTestImagePreview } from './model-test-preview'
const DEFAULT_MODEL_TEST_MESSAGE = 'Hello! This is a test message.'
const MODEL_TEST_RESPONSE_PREVIEW_MAX_LENGTH = 160
type ModelTestMappingSource = {
provider_model_name: string
@@ -15,350 +39,20 @@ type ModelTestMappingEndpoint = {
api_format: string
}
type ModelTestEndpointSource = {
api_format: string
is_active?: boolean | null
}
type ModelTestKeySource = {
api_formats?: string[] | null
is_active?: boolean | null
auth_type?: string | null
credential_kind?: string | null
oauth_managed?: boolean | null
}
export type ModelTestMappedModelOption = {
name: string
priority: number
}
const MODEL_TEST_UNSUPPORTED_API_FORMATS = new Set([
'openai:video',
'gemini:video',
'gemini:files',
])
const MODEL_TEST_OAUTH_INHERITS_PROVIDER_FORMATS = new Set([
'claude_code',
'codex',
'chatgpt_web',
'gemini_cli',
'vertex_ai',
'antigravity',
'kiro',
])
const MODEL_TEST_BEARER_INHERITS_PROVIDER_FORMATS = new Set([
'chatgpt_web',
])
const MODEL_TEST_DIAGNOSTIC_LABELS: Record<string, string> = {
pool_account_blocked: '账号已失效,需重新授权',
}
type JsonRecord = Record<string, unknown>
export function isModelTestableApiFormat(apiFormat: string | null | undefined): boolean {
const normalized = normalizeApiFormatAlias(apiFormat ?? '')
return Boolean(normalized) && !MODEL_TEST_UNSUPPORTED_API_FORMATS.has(normalized)
}
export function modelTestKeySupportsEndpoint(
key: ModelTestKeySource,
endpoint: ModelTestEndpointSource,
providerType?: string | null,
): boolean {
if (key.is_active === false) return false
const endpointFormat = normalizeApiFormatAlias(endpoint.api_format)
if (!isModelTestableApiFormat(endpointFormat)) return false
if (modelTestKeyInheritsProviderFormats(key, providerType)) return true
const keyFormats = normalizeStringList(key.api_formats ?? undefined)
if (keyFormats.length === 0) return true
return keyFormats.some(format => normalizeApiFormatAlias(format) === endpointFormat)
}
export function isModelTestableEndpoint(
endpoint: ModelTestEndpointSource,
keys: ModelTestKeySource[],
providerType?: string | null,
): boolean {
return endpoint.is_active !== false
&& isModelTestableApiFormat(endpoint.api_format)
&& keys.some(key => modelTestKeySupportsEndpoint(key, endpoint, providerType))
}
export function formatModelTestDiagnostic(value: string | null | undefined): string {
const normalized = value?.trim()
if (!normalized) return ''
return MODEL_TEST_DIAGNOSTIC_LABELS[normalized] ?? normalized
}
export function extractModelTestResponsePreview(responseBody: unknown): string | null {
const text = extractResponseText(responseBody)
if (text) return text
const reasoning = extractResponseReasoning(responseBody)
if (reasoning) return `推理:${reasoning}`
const summary = extractResponseSummary(responseBody)
if (summary) return summary
return null
}
function normalizeStringList(values: string[] | undefined): string[] {
return (values ?? [])
.map(value => value.trim())
.filter(Boolean)
}
function modelTestKeyInheritsProviderFormats(
key: ModelTestKeySource,
providerType: string | null | undefined,
): boolean {
const normalizedProviderType = providerType?.trim().toLowerCase()
if (!normalizedProviderType) return false
const authType = key.auth_type?.trim().toLowerCase()
const credentialKind = key.credential_kind?.trim().toLowerCase()
const oauthManaged = key.oauth_managed === true
|| credentialKind === 'oauth_session'
|| authType === 'oauth'
if (oauthManaged && MODEL_TEST_OAUTH_INHERITS_PROVIDER_FORMATS.has(normalizedProviderType)) {
return true
}
return authType === 'bearer'
&& MODEL_TEST_BEARER_INHERITS_PROVIDER_FORMATS.has(normalizedProviderType)
}
function isJsonRecord(value: unknown): value is JsonRecord {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
}
function compactPreviewText(value: unknown): string | null {
if (typeof value !== 'string') return null
const normalized = value.replace(/\s+/g, ' ').trim()
if (!normalized) return null
if (normalized.length <= MODEL_TEST_RESPONSE_PREVIEW_MAX_LENGTH) {
return normalized
}
return `${normalized.slice(0, MODEL_TEST_RESPONSE_PREVIEW_MAX_LENGTH - 3)}...`
}
function joinPreviewParts(parts: string[]): string | null {
return compactPreviewText(parts.filter(Boolean).join(' '))
}
function extractTextFromContentParts(value: unknown, depth = 0): string | null {
if (depth > 4) return null
const directText = compactPreviewText(value)
if (directText) return directText
if (!Array.isArray(value)) return null
const parts = value.flatMap((part) => {
if (typeof part === 'string') return [part]
if (!isJsonRecord(part)) return []
const text = compactPreviewText(part.text)
?? compactPreviewText(part.content)
?? extractTextFromContentParts(part.parts, depth + 1)
return text ? [text] : []
})
return joinPreviewParts(parts)
}
function extractResponseText(responseBody: unknown, depth = 0): string | null {
if (depth > 4 || !isJsonRecord(responseBody)) return null
const wrappedText = extractResponseText(responseBody.response, depth + 1)
?? extractResponseText(responseBody.body, depth + 1)
if (wrappedText) return wrappedText
const outputText = compactPreviewText(responseBody.output_text)
if (outputText) return outputText
const topLevelContentText = extractTextFromContentParts(responseBody.content, depth + 1)
if (topLevelContentText) return topLevelContentText
const choicesText = extractChoicesText(responseBody.choices, depth + 1)
if (choicesText) return choicesText
const outputTextParts = extractOutputText(responseBody.output, depth + 1)
if (outputTextParts) return outputTextParts
const candidateText = extractGeminiCandidateText(responseBody.candidates, depth + 1)
if (candidateText) return candidateText
return null
}
function extractResponseReasoning(responseBody: unknown, depth = 0): string | null {
if (depth > 4 || !isJsonRecord(responseBody)) return null
const wrappedReasoning = extractResponseReasoning(responseBody.response, depth + 1)
?? extractResponseReasoning(responseBody.body, depth + 1)
if (wrappedReasoning) return wrappedReasoning
const directReasoning = compactPreviewText(responseBody.reasoning_content)
?? compactPreviewText(responseBody.thinking)
if (directReasoning) return directReasoning
const topLevelReasoning = extractReasoningFromContentParts(responseBody.content, depth + 1)
if (topLevelReasoning) return topLevelReasoning
const choicesReasoning = extractChoicesReasoning(responseBody.choices, depth + 1)
if (choicesReasoning) return choicesReasoning
const outputReasoning = extractOutputReasoning(responseBody.output, depth + 1)
if (outputReasoning) return outputReasoning
return null
}
function extractChoicesText(value: unknown, depth: number): string | null {
if (!Array.isArray(value)) return null
for (const choice of value) {
if (!isJsonRecord(choice)) continue
const messageText = isJsonRecord(choice.message)
? extractTextFromContentParts(choice.message.content, depth + 1)
: null
const deltaText = isJsonRecord(choice.delta)
? extractTextFromContentParts(choice.delta.content, depth + 1)
: null
const text = messageText ?? deltaText ?? extractTextFromContentParts(choice.text, depth + 1)
if (text) return text
}
return null
}
function extractChoicesReasoning(value: unknown, depth: number): string | null {
if (!Array.isArray(value)) return null
for (const choice of value) {
if (!isJsonRecord(choice)) continue
const messageReasoning = isJsonRecord(choice.message)
? extractReasoningFromMessage(choice.message, depth + 1)
: null
const deltaReasoning = isJsonRecord(choice.delta)
? extractReasoningFromMessage(choice.delta, depth + 1)
: null
const reasoning = messageReasoning ?? deltaReasoning
if (reasoning) return reasoning
}
return null
}
function extractReasoningFromMessage(message: JsonRecord, depth: number): string | null {
return compactPreviewText(message.reasoning_content)
?? compactPreviewText(message.thinking)
?? extractReasoningFromContentParts(message.content, depth + 1)
}
function extractOutputText(value: unknown, depth: number): string | null {
if (!Array.isArray(value)) return null
for (const outputItem of value) {
if (!isJsonRecord(outputItem)) continue
const contentText = extractTextFromContentParts(outputItem.content, depth + 1)
?? extractResponseText(outputItem.response, depth + 1)
if (contentText) return contentText
}
return null
}
function extractOutputReasoning(value: unknown, depth: number): string | null {
if (!Array.isArray(value)) return null
for (const outputItem of value) {
if (!isJsonRecord(outputItem)) continue
const reasoning = extractReasoningFromContentParts(outputItem.content, depth + 1)
?? compactPreviewText(outputItem.reasoning_content)
?? compactPreviewText(outputItem.thinking)
?? extractResponseReasoning(outputItem.response, depth + 1)
if (reasoning) return reasoning
}
return null
}
function extractGeminiCandidateText(value: unknown, depth: number): string | null {
if (!Array.isArray(value)) return null
for (const candidate of value) {
if (!isJsonRecord(candidate) || !isJsonRecord(candidate.content)) continue
const text = extractTextFromContentParts(candidate.content.parts, depth + 1)
if (text) return text
}
return null
}
function extractReasoningFromContentParts(value: unknown, depth = 0): string | null {
if (depth > 4 || !Array.isArray(value)) return null
const parts = value.flatMap((part) => {
if (!isJsonRecord(part)) return []
const reasoning = compactPreviewText(part.reasoning_content)
?? compactPreviewText(part.thinking)
?? compactPreviewText(part.reasoning)
?? extractReasoningFromContentParts(part.content, depth + 1)
?? extractReasoningFromContentParts(part.parts, depth + 1)
return reasoning ? [reasoning] : []
})
return joinPreviewParts(parts)
}
function extractResponseSummary(responseBody: unknown): string | null {
if (!isJsonRecord(responseBody)) return null
if (Array.isArray(responseBody.data)) {
const embeddingDimensions = responseBody.data
.map(item => isJsonRecord(item) && Array.isArray(item.embedding) ? item.embedding.length : null)
.find((size): size is number => typeof size === 'number')
if (embeddingDimensions != null) return `Embedding 维度:${embeddingDimensions}`
if (responseBody.data.length > 0) return `返回数据:${responseBody.data.length} 条`
}
if (Array.isArray(responseBody.results)) return `Rerank 结果:${responseBody.results.length} 条`
const model = compactPreviewText(responseBody.model)
if (model) return `返回模型:${model}`
return null
}
function mappingApiFormatMatches(mapping: ProviderModelMapping, endpoint: ModelTestMappingEndpoint): boolean {
const apiFormats = normalizeStringList(mapping.api_formats)
const apiFormats = normalizeModelTestStringList(mapping.api_formats)
if (apiFormats.length === 0) return true
const endpointFormat = normalizeApiFormatAlias(endpoint.api_format)
return apiFormats.some(format => normalizeApiFormatAlias(format) === endpointFormat)
}
function mappingEndpointMatches(mapping: ProviderModelMapping, endpoint: ModelTestMappingEndpoint): boolean {
const endpointIds = normalizeStringList(mapping.endpoint_ids)
const endpointIds = normalizeModelTestStringList(mapping.endpoint_ids)
if (endpointIds.length === 0) return true
return endpointIds.includes(endpoint.id)
}
@@ -450,7 +144,11 @@ export function buildExactModelMappingTestRequest(
}
}
export function buildDefaultModelTestRequestBody(modelName: string, apiFormat?: string | null): string {
export function buildDefaultModelTestRequestBody(
modelName: string,
apiFormat?: string | null,
model?: ModelTestImageSource | null,
): string {
const normalizedApiFormat = normalizeApiFormatAlias(apiFormat ?? '')
if (normalizedApiFormat.endsWith(':embedding')) {
@@ -485,6 +183,24 @@ export function buildDefaultModelTestRequestBody(modelName: string, apiFormat?:
}, null, 2)
}
if (normalizedApiFormat === 'openai:responses' && modelSupportsImageGeneration(model)) {
return JSON.stringify({
model: modelName,
input: DEFAULT_MODEL_TEST_MESSAGE,
tools: [
{
type: 'image_generation',
size: '1024x1024',
output_format: 'png',
},
],
tool_choice: {
type: 'image_generation',
},
stream: true,
}, null, 2)
}
return JSON.stringify({
model: modelName,
messages: [
@@ -557,4 +273,4 @@ export function parseModelTestRequestHeadersDraft(
emptyError: null,
invalidTypeError: '测试请求头必须是 JSON 对象',
})
}
}