import { AI_MODEL_KINDS, type AiModelCapabilities, type AiModelStage, type AiModelKind } from './ai-model-types' import { AiModelError } from './ai-model-errors' 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 | null { return value && typeof value === 'object' && !Array.isArray(value) ? value as Record : 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, section: Record, 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 { 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() 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 => 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 }