92 lines
12 KiB
TypeScript
92 lines
12 KiB
TypeScript
import { createHash } from 'node:crypto'
|
||
import { getAiModelCapabilities, parseAiModelPublicCapabilityCatalog, AI_MODEL_PUBLIC_CATALOG_URL } from './ai-model-capabilities'
|
||
import type { AiModelStage } from './ai-model-types'
|
||
|
||
const stages: AiModelStage[] = ['analysis', 'copy', 'image']
|
||
function object(value: unknown): Record<string, any> | null { return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, any> : null }
|
||
const schemaKeys = ['type', 'const', 'enum', 'minimum', 'maximum', 'exclusiveMinimum', 'exclusiveMaximum', 'minItems', 'maxItems', 'minLength', 'maxLength', 'format', 'pattern', 'default', '$ref', 'additionalProperties', 'required']
|
||
/** Keep public parameter constraints, not examples, keys, descriptions or arbitrary metadata. */
|
||
function constraints(value: unknown, depth = 0): unknown {
|
||
if (typeof value === 'boolean') return value
|
||
const source = object(value)
|
||
if (!source || depth > 8) return null
|
||
const result: Record<string, unknown> = Object.fromEntries(schemaKeys.filter(key => source[key] !== undefined).map(key => [key, source[key]]))
|
||
for (const key of ['allOf', 'anyOf', 'oneOf']) if (Array.isArray(source[key])) result[key] = source[key].map((entry: unknown) => constraints(entry, depth + 1))
|
||
if (source.items !== undefined) result.items = constraints(source.items, depth + 1)
|
||
if (object(source.properties)) result.properties = Object.fromEntries(Object.entries(source.properties).map(([name, entry]) => [name, constraints(entry, depth + 1)]))
|
||
return result
|
||
}
|
||
function parameterNames(schema: Record<string, any> | null, depth = 0): string[] {
|
||
if (!schema || depth > 8) return []
|
||
return Array.from(new Set([...Object.keys(object(schema.properties) || {}), ...['allOf', 'anyOf', 'oneOf'].flatMap(key => Array.isArray(schema[key]) ? schema[key].flatMap((entry: unknown) => parameterNames(object(entry), depth + 1)) : [])]))
|
||
}
|
||
function propertyConstraints(schema: Record<string, any> | null, name: string, depth = 0): unknown[] {
|
||
if (!schema || depth > 8) return []
|
||
return [...(object(schema.properties)?.[name] !== undefined ? [constraints(schema.properties[name])] : []), ...['allOf', 'anyOf', 'oneOf'].flatMap(key => Array.isArray(schema[key]) ? schema[key].flatMap((entry: unknown) => propertyConstraints(object(entry), name, depth + 1)) : [])]
|
||
}
|
||
function asText(value: unknown): string | null { return typeof value === 'string' && value.length <= 200 ? value : null }
|
||
export function auditAiModelContracts(catalog: unknown, adminSnapshot?: unknown) {
|
||
const metadata = parseAiModelPublicCapabilityCatalog(catalog)
|
||
const data = object(object(catalog)?.data)!, native = object(adminSnapshot)
|
||
const localModels: any[] = (Array.isArray(native?.providers) ? native.providers : []).flatMap((provider: any) => Array.isArray(provider.models) ? provider.models : [])
|
||
const sourceModels: Array<{ section: string; model: Record<string, any> }> = data.sections.flatMap((section: any) => section.models.map((model: any) => ({ section: section.id, model })))
|
||
const ids = sourceModels.map((entry: any) => entry.model.id)
|
||
const models = sourceModels.map(({ section, model }: any) => {
|
||
const capability = getAiModelCapabilities('https://api.vip98k.com/v1', model.id, metadata.get(model.id)), current = localModels.find((entry: any) => entry.id === model.id)
|
||
const offers: any[] = Array.isArray(model.service_offers) ? model.service_offers : []
|
||
const contracts = offers.flatMap((offer: any) => {
|
||
const routes: any[] = Array.isArray(offer.route_options) ? offer.route_options : []
|
||
return routes.map((route: any) => {
|
||
const invocation = object(route.invocation) || {}, schema = object(route.input_schema) || object(invocation.input_schema)
|
||
const publishedFields = Array.isArray(invocation.supported_fields) ? invocation.supported_fields : []
|
||
const names = Array.from(new Set<string>([...parameterNames(schema), ...publishedFields.map((field: any) => field.name).filter((name: unknown) => typeof name === 'string')])).sort()
|
||
return { scopeId: asText(offer.service_scope?.id), operationId: asText(offer.public_operation_id), routeId: asText(route.id), method: asText(invocation.method), path: asText(invocation.path), contentType: asText(invocation.content_type),
|
||
defaultRoute: route.id === model.default_route_option_id && offer.service_scope?.id === model.default_service_scope_id,
|
||
inputModes: Array.isArray(invocation.input_modes) ? invocation.input_modes : [], outputModes: Array.isArray(invocation.output_modes) ? invocation.output_modes : [],
|
||
requestSchema: constraints(schema),
|
||
parameters: names.map(name => { const field = publishedFields.find((entry: any) => entry.name === name); return { name, state: asText(field?.state) || 'schema-only', declaredType: asText(field?.type), constraints: propertyConstraints(schema, name) } }) }
|
||
}) })
|
||
return { id: model.id, section, kind: capability.kind, upstreamAvailable: model.available === true && model.status === 'available',
|
||
declaredCapabilities: Array.isArray(model.capabilities) ? model.capabilities : [], taskTypes: Array.isArray(model.task_types) ? model.task_types : [],
|
||
adaptedStages: capability.allowedStages, selectedProtocols: capability.protocols || {}, adapterProtocols: metadata.get(model.id)?.protocols || [], adaptationReason: capability.reason,
|
||
contracts, production: current ? { enabled: current.enabled, stages: current.stages, verification: Object.fromEntries(stages.map(stage => [stage, current.verification?.[stage] || { status: 'untested' }])) } : null }
|
||
})
|
||
return { version: 1, source: AI_MODEL_PUBLIC_CATALOG_URL, catalogSha256: createHash('sha256').update(JSON.stringify(catalog)).digest('hex'),
|
||
modelCount: models.length, models, nativeOnly: localModels.filter((entry: any) => !ids.includes(entry.id)).map((entry: any) => ({ id: entry.id, note: '不在本次公开目录,不能沿用旧能力结论' })),
|
||
boundary: '透传字段仅表示网关接受/转发该字段,不证明具体模型执行该功能;只有实际测试记录可作为用途开放证据。未声明上限保持未知,不按模型名称推断。' }
|
||
}
|
||
|
||
function cell(value: unknown): string { return String(value ?? '未声明').replace(/\|/g, '\\|').replace(/[\r\n]/g, ' ') }
|
||
const purposeLabels = { analysis: '分析', copy: '文案', image: '生图' }
|
||
export function renderAiModelContractAudit(report: ReturnType<typeof auditAiModelContracts>, inspectedAt: string): string {
|
||
const lines = ['# AI 模型参数与能力核查', '', `核查时间:${inspectedAt}。只读取 [98k 公开模型目录](${report.source}) 及本平台管理员状态,不调用生成接口。`, '',
|
||
`公开目录共 ${report.modelCount} 个模型。本平台旧目录中另有 ${report.nativeOnly.length} 个不在本次公开目录。目录规范JSON摘要 SHA-256:\`${report.catalogSha256}\`。`, '', report.boundary, '',
|
||
'## 当前接入边界', '', '详情页只接入资料分析、卖点文案和参考图生图。视频、TTS、音乐、转录、向量和图像工具的公开声明会记录,但不因此变成已接入功能。', '',
|
||
'| 场景 | 本平台实际发送 | 本平台限制 |', '|---|---|---|',
|
||
'| 资料分析 | model、messages、max_tokens、stream=false;读图走 image_url 或 Anthropic base64 image | 最多8张;分析图缩至1024px并转JPEG;输出预算3000 tokens |',
|
||
'| 卖点文案 | model、messages、max_tokens、stream=false | 输出预算8000 tokens;产品文字最多60000字;大纲最多12屏 |',
|
||
'| 联合测试 | 一次请求返回 analysis 与 copy,两份独立验证记录 | 输出预算1800 tokens;不自动开放用途 |',
|
||
'| 原结果恢复 | GET /api/external/requests/{原请求号}/result;原Key鉴权 | 不发送生成正文、幂等头或新模型调用;410仍待核查 |', '',
|
||
'以上是本平台设置,不是98k或模型的能力上限。温度、推理、工具、联网、JSON模式、缓存等字段未由详情页调用,不标记为已实测支持。', '',
|
||
'基础测试使用读图挑战与一屏JSON大纲,不等于真实产品的事实准确性、多屏策划或商业成品验收。表内状态来自本平台当前记录,本次核查没有重新调用任何模型。部分公开分类为text的模型,其具体合同又声明图片输入;分类标签不作为用途授权依据。', '',
|
||
'## 逐模型用途和实测状态', '', '| 模型 | 公开分类 | 已适配用途 | 当前基础测试记录 | 后台已勾选用途 |', '|---|---|---|---|---|']
|
||
const statusLabels: Record<string, string> = { passed: '通过', failed: '失败', unknown: '待核查', running: '测试中', untested: '未测试', stale: '需重测' }
|
||
for (const model of report.models) lines.push(`| ${cell(model.id)} | ${cell(model.kind)} | ${model.adaptedStages.map(stage => purposeLabels[stage]).join('、') || '未接入详情页'} | ${model.adaptedStages.map(stage => `${purposeLabels[stage]}:${statusLabels[model.production?.verification[stage]?.status] || '未测试'}`).join(';') || '—'} | ${(model.production?.enabled ? model.production.stages : []).map((stage: AiModelStage) => purposeLabels[stage]).join('、') || '—'} |`)
|
||
lines.push('', '## 逐模型公开调用参数', '', '每个模型分别列出公开默认路线的合同;其他路线及完整请求Schema保存在配套JSON内。`passthrough` 意为透传,`typed` 意为网关有类型处理;二者都不等于该模型的每个可选功能已验收。`schema-only` 表示仅在公开Schema出现。未给数值约束的参数,上限未知。分支Schema按原结构保留,不能把不同分支的约束合并成一项能力承诺。', '')
|
||
for (const model of report.models) {
|
||
lines.push(`### ${cell(model.id)}`, '')
|
||
for (const protocol of model.adapterProtocols) lines.push(`- 本平台可适配合同:\`${protocol.id}\`;${protocol.supportsImageInput ? '有图片输入适配' : '无图片输入适配'};${protocol.inputField ? `参考图字段 \`${cell(protocol.inputField)}\`;` : ''}${protocol.referenceContract === 'probe-required' ? '参考图数量需要实测,8张只是平台测试上限;' : protocol.maxImages ? `平台合同限制最多${protocol.maxImages}张(仍需实测);` : ''}${protocol.requestParameters ? `请求参数 \`${cell(JSON.stringify(protocol.requestParameters))}\`;` : ''}${Object.keys(protocol.headers).length ? `公开协议头 \`${cell(JSON.stringify(protocol.headers))}\`。` : '无额外协议头。'}`)
|
||
const selected = model.contracts.filter((contract: any) => contract.defaultRoute)
|
||
if (!selected.length) { lines.push('公开目录未给出可核对的默认路线,不能自行选其他路线替代。', ''); continue }
|
||
for (const contract of selected) {
|
||
lines.push(`- 操作:\`${cell(contract.operationId)}\`;\`${cell(contract.method)} ${cell(contract.path)}\`;\`${cell(contract.contentType)}\`。`,
|
||
`- 输入声明:${contract.inputModes.map(cell).join('、') || '未声明'};输出声明:${contract.outputModes.map(cell).join('、') || '未声明'}。`,
|
||
`- 参数:${contract.parameters.map((entry: any) => `\`${cell(entry.name)}\`(${cell(entry.state)})`).join('、') || '未声明'}。`)
|
||
const constrained = contract.parameters.filter((entry: any) => entry.constraints.some((item: any) => item === false || item && Object.keys(item).some(key => !['type', '$ref'].includes(key))))
|
||
lines.push(`- 显式约束:${constrained.map((entry: any) => `\`${cell(entry.name)}\`: \`${cell(JSON.stringify(entry.constraints))}\``).join(';') || '未发布具体数值上限;不能把平台默认值当成模型上限'}。`, '')
|
||
}
|
||
}
|
||
if (report.nativeOnly.length) lines.push('## 旧目录差异', '', ...report.nativeOnly.map((entry: any) => `- ${cell(entry.id)}:${entry.note}。`), '')
|
||
return lines.join('\n')
|
||
}
|