gongxiangp2/lib/ai-model-contract-audit.ts

92 lines
12 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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')
}