118 lines
11 KiB
TypeScript
118 lines
11 KiB
TypeScript
import type { AiModelCapabilities, AiModelStage } from './ai-model-types'
|
|
import { AiModelError } from './ai-model-access'
|
|
|
|
/** 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
|
|
}
|
|
|
|
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
|
|
}
|
|
return { version: 1, inputModalities, outputModalities, endpoints, sourceUrl, inputDeclared: true, outputDeclared: true, endpointsDeclared: true, unavailable: raw.available === false }
|
|
}
|
|
|
|
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 selected = offers.find(offer => offer.is_default === true && object(offer.service_scope)?.id === model.default_service_scope_id)
|
|
const invocation = object(offers.length ? selected?.invocation : model.invocation)
|
|
const rawInputModes = selected?.input_modes ?? model.input_modes, rawOutputModes = 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', '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()
|
|
const inputModalities = normalizeModes(inputModes || (textModel ? ['text'] : []))
|
|
const outputModalities = normalizeModes(outputModes || (textModel ? ['text'] : imageModel ? ['image'] : []))
|
|
const headers = object(invocation?.headers)
|
|
const endpoints: string[] = []
|
|
if (model.selection_required !== true && invocation?.method === 'POST' && headers && Object.keys(headers).length === 0) {
|
|
if (invocation.path === '/v1/chat/completions' && invocation.content_type === 'application/json') endpoints.push('/chat/completions')
|
|
if (invocation.path === '/v1/images/edits' && invocation.content_type === 'multipart/form-data') endpoints.push('/images/edits')
|
|
}
|
|
result.set(model.id, { version: 1, inputModalities, outputModalities, endpoints, 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: imageModel ? 'image' : textModel ? model.modality === 'multimodal' ? 'vision' : 'text' : 'other' })
|
|
}
|
|
}
|
|
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,
|
|
...(['text', 'vision', 'image', 'other', 'unknown'].includes(String(raw.kindHint)) ? { kindHint: raw.kindHint as AiModelCapabilities['kind'] } : {}) }
|
|
}
|
|
|
|
/** 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,
|
|
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)
|
|
// Missing public fields do not erase real acceptance evidence. Explicit capability withdrawal does.
|
|
const verified: AiModelStage[] = baseUrl === 'https://api.vip98k.com/v1' ? modelId === 'gemini-2.5-flash' ? ['analysis', 'copy'] : modelId === 'nano-banana-2' ? ['image'] : [] : []
|
|
const verifiedAllowed = verified.filter(stage => !evidence?.unavailable && evidence?.kindHint !== 'other' && (!evidence?.inputDeclared || evidence.inputModalities.includes('text') && (stage === 'copy' || evidence.inputModalities.includes('image'))) && (!evidence?.outputDeclared || evidence.outputModalities.includes(stage === 'image' ? 'image' : 'text')) && (!evidence?.endpointsDeclared || evidence.endpoints.some(endpoint => endpoint === (stage === 'image' ? '/images/edits' : '/chat/completions') || endpoint === (stage === 'image' ? '/v1/images/edits' : '/v1/chat/completions'))))
|
|
if (verifiedAllowed.length) return { kind: verifiedAllowed.includes('image') ? 'image' : verifiedAllowed.includes('analysis') ? 'vision' : 'text', allowedStages: verifiedAllowed, source: 'verified', reason: '依据本平台已完成的实际接口验证' }
|
|
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 chat = evidence.endpoints.includes('/chat/completions') || evidence.endpoints.includes('/v1/chat/completions')
|
|
const imageEdit = evidence.endpoints.includes('/images/edits') || evidence.endpoints.includes('/v1/images/edits')
|
|
const allowedStages: AiModelStage[] = []
|
|
if (!evidence.unavailable && evidence.kindHint !== 'other' && textInput && textOutput && chat) { if (imageInput) allowedStages.push('analysis'); allowedStages.push('copy') }
|
|
if (!evidence.unavailable && evidence.kindHint !== 'other' && textInput && imageOutput && imageInput && imageEdit) allowedStages.push('image')
|
|
const kind = evidence.kindHint || (imageOutput ? 'image' : textOutput && textInput ? imageInput ? 'vision' : 'text' : evidence.outputModalities.length ? 'other' : 'unknown')
|
|
return { kind, allowedStages, source: 'upstream', reason: evidence.unavailable ? '上游已停用该模型' : allowedStages.length ? '依据上游公布的输入、输出与接口能力' : kind === 'image' ? '图片模型尚不支持当前产品参考图接口' : kind === 'other' ? '当前详情页不支持该模型类型' : '上游能力尚不匹配当前详情页接口' }
|
|
}
|