174 lines
15 KiB
TypeScript
174 lines
15 KiB
TypeScript
import { AI_MODEL_KINDS, type AiModelCapabilities, type AiModelStage, type AiModelKind } from './ai-model-types'
|
|
import { AiModelError } from './ai-model-access'
|
|
import { aiModelImageRequestParameters, parseAiModelProtocols, validateStoredAiModelProtocols, type AiModelProtocol } from './ai-model-protocol'
|
|
|
|
/** Server-owned evidence. Never accepted from configuration writes or inferred from model names. */
|
|
export interface AiModelCapabilityMetadata {
|
|
version: 1
|
|
inputModalities: string[]
|
|
outputModalities: string[]
|
|
endpoints: string[]
|
|
sourceUrl: string | null
|
|
kindHint?: AiModelCapabilities['kind']
|
|
inputDeclared?: boolean
|
|
outputDeclared?: boolean
|
|
endpointsDeclared?: boolean
|
|
unavailable?: boolean
|
|
protocols?: AiModelProtocol[]
|
|
}
|
|
|
|
function object(value: unknown): Record<string, unknown> | null { return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : null }
|
|
function strings(value: unknown): string[] | null {
|
|
return Array.isArray(value) && value.length <= 20 && value.every(item => typeof item === 'string' && item.length <= 100) ? Array.from(new Set(value as string[])).sort() : null
|
|
}
|
|
|
|
/** Explicit metadata contract used by standard model directories. Missing fields stay unknown. */
|
|
export function parseAiModelCapabilityMetadata(value: unknown, sourceUrl: string | null = null): AiModelCapabilityMetadata | null {
|
|
const raw = object(value)
|
|
if (!raw) return null
|
|
const inputModalities = strings(raw.input_modalities)
|
|
const outputModalities = strings(raw.output_modalities)
|
|
const endpoints = strings(raw.endpoints)
|
|
if (!inputModalities || !outputModalities || !endpoints) {
|
|
if (['input_modalities', 'output_modalities', 'endpoints'].some(field => Object.prototype.hasOwnProperty.call(raw, field))) throw new AiModelError('上游模型能力字段不完整,未保存本次同步', 502, 'catalog_invalid')
|
|
if (raw.available === false) return { version: 1, inputModalities: [], outputModalities: [], endpoints: [], sourceUrl, unavailable: true }
|
|
return null
|
|
}
|
|
const protocols: AiModelProtocol[] = []
|
|
for (const endpoint of endpoints) {
|
|
const path = endpoint.startsWith('/v1/') ? endpoint : `/v1${endpoint}`
|
|
if (path === '/v1/chat/completions') protocols.push({ id: 'openai-chat', path, headers: {}, supportsImageInput: inputModalities.includes('image') })
|
|
if (path === '/v1/images/edits') protocols.push({ id: 'openai-image-edit', path, headers: {}, inputField: 'image[]', supportsImageInput: inputModalities.includes('image') })
|
|
}
|
|
return { version: 1, inputModalities, outputModalities, endpoints, protocols, sourceUrl, inputDeclared: true, outputDeclared: true, endpointsDeclared: true, unavailable: raw.available === false }
|
|
}
|
|
|
|
function publicKind(model: Record<string, unknown>, section: Record<string, unknown>, input: string[], output: string[]): AiModelKind {
|
|
const capabilities = strings(model.capabilities) || [], tasks = strings(model.task_types) || []
|
|
if (model.category === 'three_d_space' || tasks.includes('interactive_3d') || output.includes('interactive_3d')) return 'three-d'
|
|
if (model.category === 'music_generation' || tasks.includes('text_to_music')) return 'music'
|
|
if (model.type === 'video' || model.modality === 'video' || output.includes('video')) return 'video'
|
|
if (tasks.includes('text_to_speech') || input.includes('text') && output.includes('audio')) return 'tts'
|
|
if (capabilities.some(value => ['audio_transcription', 'speech_to_text'].includes(value)) || input.some(value => value.startsWith('audio')) && output.includes('text')) return 'asr'
|
|
if (model.type === 'embedding' || model.modality === 'embedding' || capabilities.includes('openai.embeddings') || output.includes('embedding')) return 'embedding'
|
|
if (model.type === 'image' || model.modality === 'image') return capabilities.some(value => ['text_to_image', 'image_to_image', 'openai.images.generations', 'openai.images.edits'].includes(value)) ? 'image' : 'image-tool'
|
|
if (model.type === 'model' && section.id === 'chat') return model.modality === 'multimodal' ? 'vision' : model.modality === 'text' ? 'text' : 'other'
|
|
return 'other'
|
|
}
|
|
|
|
export const AI_MODEL_PUBLIC_CATALOG_URL = 'https://api.vip98k.com/api/v2/catalog/models'
|
|
|
|
/** Known provider's public schema, joined only to IDs already authorized by its private /models response. */
|
|
export function parseAiModelPublicCapabilityCatalog(value: unknown): Map<string, AiModelCapabilityMetadata> {
|
|
const raw = object(value), data = object(raw?.data)
|
|
if (raw?.code !== 0 || data?.schema_version !== '2.2' || !Array.isArray(data.sections) || data.sections.length > 20) throw new AiModelError('上游能力目录格式不兼容', 502, 'capability_catalog_invalid')
|
|
const result = new Map<string, AiModelCapabilityMetadata>()
|
|
for (const sectionValue of data.sections) {
|
|
const section = object(sectionValue)
|
|
if (!section || !Array.isArray(section.models) || section.models.length > 5000) throw new AiModelError('上游能力目录格式不兼容', 502, 'capability_catalog_invalid')
|
|
for (const modelValue of section.models) {
|
|
const model = object(modelValue)
|
|
if (!model || typeof model.id !== 'string' || !model.id || model.id.length > 200 || result.has(model.id)) throw new AiModelError('上游能力目录模型编号无效或重复', 502, 'capability_catalog_invalid')
|
|
const offers = Array.isArray(model.service_offers) ? model.service_offers.map(object).filter((offer): offer is Record<string, unknown> => Boolean(offer)) : []
|
|
// Do not combine non-default service offers or route-specific protocols into the default request path.
|
|
const protocols = parseAiModelProtocols(model)
|
|
const selectedProtocol = protocols[0]
|
|
const selected = offers.find(offer => object(offer.service_scope)?.id === selectedProtocol?.scopeId &&
|
|
Array.isArray(offer.route_options) && offer.route_options.some(route => object(route)?.id === selectedProtocol?.routeId)) ||
|
|
offers.find(offer => offer.is_default === true && object(offer.service_scope)?.id === model.default_service_scope_id)
|
|
const selectedRoute = selectedProtocol?.routeId && Array.isArray(selected?.route_options)
|
|
? selected.route_options.map(object).find(route => route?.id === selectedProtocol.routeId) : null
|
|
// The adapter selects a concrete route. Its declarations take precedence
|
|
// over the model/offer summary, which can describe a different route.
|
|
const rawInputModes = selectedRoute?.input_modes ?? selected?.input_modes ?? model.input_modes
|
|
const rawOutputModes = selectedRoute?.output_modes ?? selected?.output_modes ?? model.output_modes
|
|
const inputModes = strings(rawInputModes), outputModes = strings(rawOutputModes)
|
|
if (rawInputModes !== undefined && inputModes === null || rawOutputModes !== undefined && outputModes === null) throw new AiModelError('上游能力目录模态字段无效', 502, 'capability_catalog_invalid')
|
|
const textModel = model.type === 'model' && ['text', 'multimodal'].includes(String(model.modality)) && section.id === 'chat'
|
|
const declaredCapabilities = strings(model.capabilities) || []
|
|
const imageModel = (model.type === 'image' || model.modality === 'image') && declaredCapabilities.some(capability => ['text_to_image', 'image_to_image', 'openai.images.generations', 'openai.images.edits'].includes(capability))
|
|
const normalizeModes = (modes: string[]) => Array.from(new Set(modes.map(mode => ['image_url', 'image_base64'].includes(mode) ? 'image' : mode === 'prompt' ? 'text' : mode))).sort()
|
|
// A generic URL mode alone proves nothing about file type. The parsed
|
|
// image adapter has already validated the concrete reference parameter.
|
|
const inputModalities = normalizeModes([...(inputModes || (textModel ? ['text'] : [])),
|
|
...(imageModel && selectedProtocol?.supportsImageInput ? ['image'] : [])])
|
|
const outputModalities = normalizeModes(outputModes || (textModel ? ['text'] : imageModel ? ['image'] : []))
|
|
const endpoints = protocols.map(protocol => protocol.path)
|
|
const kindHint = publicKind(model, section, inputModes || [], outputModes || [])
|
|
result.set(model.id, { version: 1, inputModalities, outputModalities, endpoints, protocols, sourceUrl: AI_MODEL_PUBLIC_CATALOG_URL,
|
|
inputDeclared: inputModes !== null, outputDeclared: outputModes !== null, endpointsDeclared: false,
|
|
unavailable: model.available === false || typeof model.status === 'string' && model.status !== 'available' || selected?.available === false || typeof selected?.status === 'string' && selected.status !== 'available',
|
|
kindHint })
|
|
}
|
|
}
|
|
if (result.size > 5000) throw new AiModelError('上游能力目录过大', 502, 'capability_catalog_invalid')
|
|
return result
|
|
}
|
|
|
|
function storedMetadata(value: unknown): AiModelCapabilityMetadata | null {
|
|
const raw = object(value)
|
|
if (!raw || raw.version !== 1) return null
|
|
const inputModalities = strings(raw.inputModalities), outputModalities = strings(raw.outputModalities), endpoints = strings(raw.endpoints)
|
|
if (!inputModalities || !outputModalities || !endpoints || raw.sourceUrl !== null && typeof raw.sourceUrl !== 'string') return null
|
|
return { version: 1, inputModalities, outputModalities, endpoints, sourceUrl: raw.sourceUrl as string | null,
|
|
inputDeclared: raw.inputDeclared === true, outputDeclared: raw.outputDeclared === true, endpointsDeclared: raw.endpointsDeclared === true, unavailable: raw.unavailable === true,
|
|
protocols: validateStoredAiModelProtocols(raw.protocols),
|
|
...(AI_MODEL_KINDS.includes(raw.kindHint as AiModelKind) ? { kindHint: raw.kindHint as AiModelKind } : {}) }
|
|
}
|
|
|
|
/** Stable across PostgreSQL JSONB property ordering, so unchanged sync does not invalidate active jobs. */
|
|
export function aiModelCapabilityFingerprint(value: unknown): string { return JSON.stringify(storedMetadata(value)) }
|
|
|
|
export function mergeAiModelCapabilityMetadata(primary: AiModelCapabilityMetadata | null, additional: AiModelCapabilityMetadata | null): AiModelCapabilityMetadata | null {
|
|
if (!primary || !additional) return primary || additional
|
|
if (primary.unavailable && !primary.inputDeclared && !primary.outputDeclared && !primary.endpointsDeclared) return { ...additional, unavailable: true }
|
|
return { ...primary, kindHint: additional.kindHint || primary.kindHint, protocols: additional.protocols || [], endpoints: additional.endpoints,
|
|
inputModalities: additional.inputDeclared ? primary.inputModalities.filter(value => additional.inputModalities.includes(value)) : primary.inputModalities,
|
|
outputModalities: additional.outputDeclared ? primary.outputModalities.filter(value => additional.outputModalities.includes(value)) : primary.outputModalities,
|
|
unavailable: primary.unavailable === true || additional.unavailable === true }
|
|
}
|
|
|
|
export function getAiModelCapabilities(baseUrl: string, modelId: string, metadata?: unknown): AiModelCapabilities {
|
|
const evidence = storedMetadata(metadata)
|
|
void baseUrl; void modelId
|
|
if (!evidence) return { kind: 'unknown', allowedStages: [], source: 'unknown', reason: '上游未提供可核验能力,暂不能开放' }
|
|
const textOutput = evidence.outputModalities.includes('text'), imageOutput = evidence.outputModalities.includes('image'), imageInput = evidence.inputModalities.includes('image'), textInput = evidence.inputModalities.includes('text')
|
|
const protocols = evidence.protocols || []
|
|
const chat = protocols.some(protocol => ['openai-chat', 'anthropic-messages'].includes(protocol.id))
|
|
const imageEdit = protocols.some(protocol => ['openai-image-edit', 'openai-image-generate', 'async-image-task'].includes(protocol.id) && protocol.supportsImageInput)
|
|
const allowedStages: AiModelStage[] = []
|
|
const kind = evidence.kindHint || (evidence.outputModalities.includes('audio') && textInput ? 'tts' : evidence.inputModalities.some(value => value.startsWith('audio')) && textOutput ? 'asr' : evidence.outputModalities.includes('video') ? 'video' : evidence.outputModalities.includes('embedding') ? 'embedding' : imageOutput ? 'image' : textOutput && textInput ? imageInput ? 'vision' : 'text' : evidence.outputModalities.length ? 'other' : 'unknown')
|
|
if (!evidence.unavailable && ['text', 'vision', 'image'].includes(kind) && chat && (!evidence.inputDeclared || textInput) && (!evidence.outputDeclared || textOutput)) { if (!evidence.inputDeclared || imageInput) allowedStages.push('analysis'); allowedStages.push('copy') }
|
|
if (!evidence.unavailable && kind === 'image' && imageEdit && (!evidence.outputDeclared || imageOutput)) allowedStages.push('image')
|
|
const analysisProtocol = getAiModelStageProtocol(metadata, 'analysis'), copyProtocol = getAiModelStageProtocol(metadata, 'copy')
|
|
// Both checks must use the same native route. Image-input admission is proven by
|
|
// the analysis protocol; its explicit input flag may differ on legacy catalogs.
|
|
const transport = (protocol: AiModelProtocol | null) => protocol && JSON.stringify({ ...protocol, supportsImageInput: undefined })
|
|
const combinedTextTest = allowedStages.includes('analysis') && allowedStages.includes('copy') && !!analysisProtocol?.supportsImageInput && transport(analysisProtocol) === transport(copyProtocol)
|
|
const referenceProbe = allowedStages.includes('image') && getAiModelStageProtocol(metadata, 'image')?.referenceContract === 'probe-required'
|
|
const reason = evidence.unavailable ? '上游当前不可用,暂不能测试或开放'
|
|
: referenceProbe ? '参考图采用标准图片参数;通过实际测试后可开放'
|
|
: allowedStages.length ? '接口已适配;通过对应用途的实际测试后可开放'
|
|
: kind === 'image' ? imageEdit ? '参考图待适配:当前路线的输出声明与生图要求不一致'
|
|
: imageInput ? '参考图待适配:目录已声明图片输入,当前传入方式尚未接入'
|
|
: '参考图待适配:当前目录尚未确认可用的参考图传入方式'
|
|
: ['text', 'vision'].includes(kind) ? '输入输出或接口不符合详情页分析、文案要求'
|
|
: '该类型不适用于详情页的分析、文案或生图'
|
|
return { kind, allowedStages, combinedTextTest, source: 'upstream', protocols: Object.fromEntries(allowedStages.map(stage => {
|
|
const protocol = getAiModelStageProtocol(metadata, stage)!
|
|
return [stage, { id: protocol.id, path: protocol.path, scopeId: protocol.scopeId, routeId: protocol.routeId, operationId: protocol.operationId,
|
|
...(protocol.maxImages ? { maxImages: protocol.maxImages } : {}), ...(protocol.maxPromptLength ? { maxPromptLength: protocol.maxPromptLength } : {}),
|
|
...(stage === 'image' ? { inputField: protocol.inputField, referenceTransport: protocol.referenceTransport || (protocol.id === 'openai-image-edit' ? 'multipart' : 'json-inline'), requestParameters: aiModelImageRequestParameters(protocol) } : {}),
|
|
...(protocol.referenceContract === 'probe-required' ? { referenceLimitSource: 'verification' } : {}) }]
|
|
})), reason }
|
|
}
|
|
|
|
export function getAiModelStageProtocol(metadata: unknown, stage: AiModelStage): AiModelProtocol | null {
|
|
const evidence = storedMetadata(metadata)
|
|
if (!evidence || evidence.unavailable) return null
|
|
const protocol = (evidence.protocols || []).find(protocol => stage === 'image'
|
|
? ['openai-image-edit', 'openai-image-generate', 'async-image-task'].includes(protocol.id) && protocol.supportsImageInput
|
|
: ['openai-chat', 'anthropic-messages'].includes(protocol.id)) || null
|
|
return protocol && stage === 'analysis' && !evidence.inputDeclared ? { ...protocol, supportsImageInput: true } : protocol
|
|
}
|