release: opensource snapshot 2026-02-27 19:25:00
This commit is contained in:
462
scripts/check-model-config-contract.mjs
Normal file
462
scripts/check-model-config-contract.mjs
Normal file
@@ -0,0 +1,462 @@
|
||||
let prisma
|
||||
|
||||
const STRICT = process.argv.includes('--strict')
|
||||
const MODEL_FIELDS = [
|
||||
'analysisModel',
|
||||
'characterModel',
|
||||
'locationModel',
|
||||
'storyboardModel',
|
||||
'editModel',
|
||||
'videoModel',
|
||||
]
|
||||
const MAX_SAMPLES = 200
|
||||
const CAPABILITY_NAMESPACES = new Set(['llm', 'image', 'video', 'audio', 'lipsync'])
|
||||
const MODEL_TYPES = new Set(['llm', 'image', 'video', 'audio', 'lipsync'])
|
||||
const CAPABILITY_NAMESPACE_ALLOWED_FIELDS = {
|
||||
llm: new Set(['reasoningEffortOptions', 'fieldI18n']),
|
||||
image: new Set(['resolutionOptions', 'fieldI18n']),
|
||||
video: new Set([
|
||||
'durationOptions',
|
||||
'fpsOptions',
|
||||
'resolutionOptions',
|
||||
'firstlastframe',
|
||||
'supportGenerateAudio',
|
||||
'fieldI18n',
|
||||
]),
|
||||
audio: new Set(['voiceOptions', 'rateOptions', 'fieldI18n']),
|
||||
lipsync: new Set(['modeOptions', 'fieldI18n']),
|
||||
}
|
||||
|
||||
const CAPABILITY_NAMESPACE_I18N_FIELDS = {
|
||||
llm: {
|
||||
reasoningEffort: 'reasoningEffortOptions',
|
||||
},
|
||||
image: {
|
||||
resolution: 'resolutionOptions',
|
||||
},
|
||||
video: {
|
||||
duration: 'durationOptions',
|
||||
fps: 'fpsOptions',
|
||||
resolution: 'resolutionOptions',
|
||||
},
|
||||
audio: {
|
||||
voice: 'voiceOptions',
|
||||
rate: 'rateOptions',
|
||||
},
|
||||
lipsync: {
|
||||
mode: 'modeOptions',
|
||||
},
|
||||
}
|
||||
|
||||
function isRecord(value) {
|
||||
return !!value && typeof value === 'object' && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function isNonEmptyString(value) {
|
||||
return typeof value === 'string' && value.trim().length > 0
|
||||
}
|
||||
|
||||
function isStringArray(value) {
|
||||
return Array.isArray(value) && value.every((item) => isNonEmptyString(item))
|
||||
}
|
||||
|
||||
function isNumberArray(value) {
|
||||
return Array.isArray(value) && value.every((item) => typeof item === 'number' && Number.isFinite(item))
|
||||
}
|
||||
|
||||
function parseModelKeyStrict(value) {
|
||||
if (!isNonEmptyString(value)) return null
|
||||
const raw = value.trim()
|
||||
const marker = raw.indexOf('::')
|
||||
if (marker === -1) return null
|
||||
const provider = raw.slice(0, marker).trim()
|
||||
const modelId = raw.slice(marker + 2).trim()
|
||||
if (!provider || !modelId) return null
|
||||
return {
|
||||
provider,
|
||||
modelId,
|
||||
modelKey: `${provider}::${modelId}`,
|
||||
}
|
||||
}
|
||||
|
||||
function addSample(summary, sample) {
|
||||
if (summary.samples.length >= MAX_SAMPLES) return
|
||||
summary.samples.push(sample)
|
||||
}
|
||||
|
||||
function pushIssue(issues, field, message) {
|
||||
issues.push({ field, message })
|
||||
}
|
||||
|
||||
function isI18nKey(value) {
|
||||
return isNonEmptyString(value) && value.includes('.')
|
||||
}
|
||||
|
||||
function validateAllowedFields(issues, namespace, namespaceValue) {
|
||||
if (!isRecord(namespaceValue)) return
|
||||
const allowedFields = CAPABILITY_NAMESPACE_ALLOWED_FIELDS[namespace]
|
||||
for (const field of Object.keys(namespaceValue)) {
|
||||
if (allowedFields.has(field)) continue
|
||||
if (field === 'i18n') {
|
||||
pushIssue(issues, `capabilities.${namespace}.${field}`, 'use fieldI18n instead of i18n')
|
||||
continue
|
||||
}
|
||||
pushIssue(issues, `capabilities.${namespace}.${field}`, `unknown capability field: ${field}`)
|
||||
}
|
||||
}
|
||||
|
||||
function validateFieldI18nMap(issues, namespace, namespaceValue) {
|
||||
if (!isRecord(namespaceValue)) return
|
||||
if (namespaceValue.fieldI18n === undefined) return
|
||||
if (!isRecord(namespaceValue.fieldI18n)) {
|
||||
pushIssue(issues, `capabilities.${namespace}.fieldI18n`, 'fieldI18n must be an object')
|
||||
return
|
||||
}
|
||||
|
||||
const allowedI18nFields = CAPABILITY_NAMESPACE_I18N_FIELDS[namespace]
|
||||
for (const [fieldName, fieldConfig] of Object.entries(namespaceValue.fieldI18n)) {
|
||||
if (!(fieldName in allowedI18nFields)) {
|
||||
pushIssue(
|
||||
issues,
|
||||
`capabilities.${namespace}.fieldI18n.${fieldName}`,
|
||||
`unknown i18n field: ${fieldName}`,
|
||||
)
|
||||
continue
|
||||
}
|
||||
if (!isRecord(fieldConfig)) {
|
||||
pushIssue(
|
||||
issues,
|
||||
`capabilities.${namespace}.fieldI18n.${fieldName}`,
|
||||
'field i18n config must be an object',
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
if (fieldConfig.labelKey !== undefined && !isI18nKey(fieldConfig.labelKey)) {
|
||||
pushIssue(
|
||||
issues,
|
||||
`capabilities.${namespace}.fieldI18n.${fieldName}.labelKey`,
|
||||
'labelKey must be an i18n key',
|
||||
)
|
||||
}
|
||||
if (fieldConfig.unitKey !== undefined && !isI18nKey(fieldConfig.unitKey)) {
|
||||
pushIssue(
|
||||
issues,
|
||||
`capabilities.${namespace}.fieldI18n.${fieldName}.unitKey`,
|
||||
'unitKey must be an i18n key',
|
||||
)
|
||||
}
|
||||
if (fieldConfig.optionLabelKeys !== undefined) {
|
||||
if (!isRecord(fieldConfig.optionLabelKeys)) {
|
||||
pushIssue(
|
||||
issues,
|
||||
`capabilities.${namespace}.fieldI18n.${fieldName}.optionLabelKeys`,
|
||||
'optionLabelKeys must be an object',
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
const optionFieldName = allowedI18nFields[fieldName]
|
||||
const allowedOptionsRaw = namespaceValue[optionFieldName]
|
||||
const allowedOptions = Array.isArray(allowedOptionsRaw)
|
||||
? new Set(allowedOptionsRaw.map((value) => String(value)))
|
||||
: null
|
||||
|
||||
for (const [optionValue, optionLabelKey] of Object.entries(fieldConfig.optionLabelKeys)) {
|
||||
if (!isI18nKey(optionLabelKey)) {
|
||||
pushIssue(
|
||||
issues,
|
||||
`capabilities.${namespace}.fieldI18n.${fieldName}.optionLabelKeys.${optionValue}`,
|
||||
'option label must be an i18n key',
|
||||
)
|
||||
}
|
||||
if (allowedOptions && !allowedOptions.has(optionValue)) {
|
||||
pushIssue(
|
||||
issues,
|
||||
`capabilities.${namespace}.fieldI18n.${fieldName}.optionLabelKeys.${optionValue}`,
|
||||
`option ${optionValue} is not defined in ${optionFieldName}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function validateCapabilities(modelType, capabilities) {
|
||||
const issues = []
|
||||
if (!MODEL_TYPES.has(modelType)) {
|
||||
pushIssue(issues, 'type', 'type must be llm/image/video/audio/lipsync')
|
||||
return issues
|
||||
}
|
||||
if (capabilities === undefined || capabilities === null) return issues
|
||||
if (!isRecord(capabilities)) {
|
||||
pushIssue(issues, 'capabilities', 'capabilities must be an object')
|
||||
return issues
|
||||
}
|
||||
|
||||
for (const namespace of Object.keys(capabilities)) {
|
||||
if (!CAPABILITY_NAMESPACES.has(namespace)) {
|
||||
pushIssue(issues, `capabilities.${namespace}`, `unknown capabilities namespace: ${namespace}`)
|
||||
continue
|
||||
}
|
||||
if (namespace !== modelType) {
|
||||
pushIssue(issues, `capabilities.${namespace}`, `namespace ${namespace} is not allowed for model type ${modelType}`)
|
||||
}
|
||||
}
|
||||
|
||||
const llm = capabilities.llm
|
||||
if (llm !== undefined) {
|
||||
if (!isRecord(llm)) {
|
||||
pushIssue(issues, 'capabilities.llm', 'llm capabilities must be an object')
|
||||
} else {
|
||||
validateAllowedFields(issues, 'llm', llm)
|
||||
if (llm.reasoningEffortOptions !== undefined && !isStringArray(llm.reasoningEffortOptions)) {
|
||||
pushIssue(issues, 'capabilities.llm.reasoningEffortOptions', 'must be string array')
|
||||
}
|
||||
validateFieldI18nMap(issues, 'llm', llm)
|
||||
}
|
||||
}
|
||||
|
||||
const image = capabilities.image
|
||||
if (image !== undefined) {
|
||||
if (!isRecord(image)) {
|
||||
pushIssue(issues, 'capabilities.image', 'image capabilities must be an object')
|
||||
} else {
|
||||
validateAllowedFields(issues, 'image', image)
|
||||
if (image.resolutionOptions !== undefined && !isStringArray(image.resolutionOptions)) {
|
||||
pushIssue(issues, 'capabilities.image.resolutionOptions', 'must be string array')
|
||||
}
|
||||
validateFieldI18nMap(issues, 'image', image)
|
||||
}
|
||||
}
|
||||
|
||||
const video = capabilities.video
|
||||
if (video !== undefined) {
|
||||
if (!isRecord(video)) {
|
||||
pushIssue(issues, 'capabilities.video', 'video capabilities must be an object')
|
||||
} else {
|
||||
validateAllowedFields(issues, 'video', video)
|
||||
if (video.durationOptions !== undefined && !isNumberArray(video.durationOptions)) {
|
||||
pushIssue(issues, 'capabilities.video.durationOptions', 'must be number array')
|
||||
}
|
||||
if (video.fpsOptions !== undefined && !isNumberArray(video.fpsOptions)) {
|
||||
pushIssue(issues, 'capabilities.video.fpsOptions', 'must be number array')
|
||||
}
|
||||
if (video.resolutionOptions !== undefined && !isStringArray(video.resolutionOptions)) {
|
||||
pushIssue(issues, 'capabilities.video.resolutionOptions', 'must be string array')
|
||||
}
|
||||
if (video.supportGenerateAudio !== undefined && typeof video.supportGenerateAudio !== 'boolean') {
|
||||
pushIssue(issues, 'capabilities.video.supportGenerateAudio', 'must be boolean')
|
||||
}
|
||||
if (video.firstlastframe !== undefined && typeof video.firstlastframe !== 'boolean') {
|
||||
pushIssue(issues, 'capabilities.video.firstlastframe', 'must be boolean')
|
||||
}
|
||||
validateFieldI18nMap(issues, 'video', video)
|
||||
}
|
||||
}
|
||||
|
||||
const audio = capabilities.audio
|
||||
if (audio !== undefined) {
|
||||
if (!isRecord(audio)) {
|
||||
pushIssue(issues, 'capabilities.audio', 'audio capabilities must be an object')
|
||||
} else {
|
||||
validateAllowedFields(issues, 'audio', audio)
|
||||
if (audio.voiceOptions !== undefined && !isStringArray(audio.voiceOptions)) {
|
||||
pushIssue(issues, 'capabilities.audio.voiceOptions', 'must be string array')
|
||||
}
|
||||
if (audio.rateOptions !== undefined && !isStringArray(audio.rateOptions)) {
|
||||
pushIssue(issues, 'capabilities.audio.rateOptions', 'must be string array')
|
||||
}
|
||||
validateFieldI18nMap(issues, 'audio', audio)
|
||||
}
|
||||
}
|
||||
|
||||
const lipsync = capabilities.lipsync
|
||||
if (lipsync !== undefined) {
|
||||
if (!isRecord(lipsync)) {
|
||||
pushIssue(issues, 'capabilities.lipsync', 'lipsync capabilities must be an object')
|
||||
} else {
|
||||
validateAllowedFields(issues, 'lipsync', lipsync)
|
||||
if (lipsync.modeOptions !== undefined && !isStringArray(lipsync.modeOptions)) {
|
||||
pushIssue(issues, 'capabilities.lipsync.modeOptions', 'must be string array')
|
||||
}
|
||||
validateFieldI18nMap(issues, 'lipsync', lipsync)
|
||||
}
|
||||
}
|
||||
|
||||
return issues
|
||||
}
|
||||
|
||||
async function main() {
|
||||
let PrismaClient
|
||||
try {
|
||||
({ PrismaClient } = await import('@prisma/client'))
|
||||
} catch {
|
||||
throw new Error('MISSING_DEPENDENCY: @prisma/client is not installed, run npm install first')
|
||||
}
|
||||
|
||||
prisma = new PrismaClient()
|
||||
const summary = {
|
||||
generatedAt: new Date().toISOString(),
|
||||
userPreference: {
|
||||
total: 0,
|
||||
invalidModelKeyFields: 0,
|
||||
invalidCustomModelsJson: 0,
|
||||
invalidCustomModelShape: 0,
|
||||
invalidCapabilities: 0,
|
||||
},
|
||||
novelPromotionProject: {
|
||||
total: 0,
|
||||
invalidModelKeyFields: 0,
|
||||
},
|
||||
samples: [],
|
||||
}
|
||||
|
||||
const userPrefs = await prisma.userPreference.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
customModels: true,
|
||||
analysisModel: true,
|
||||
characterModel: true,
|
||||
locationModel: true,
|
||||
storyboardModel: true,
|
||||
editModel: true,
|
||||
videoModel: true,
|
||||
},
|
||||
})
|
||||
|
||||
for (const pref of userPrefs) {
|
||||
summary.userPreference.total += 1
|
||||
for (const field of MODEL_FIELDS) {
|
||||
const rawValue = pref[field]
|
||||
if (!rawValue) continue
|
||||
if (!parseModelKeyStrict(rawValue)) {
|
||||
summary.userPreference.invalidModelKeyFields += 1
|
||||
addSample(summary, {
|
||||
table: 'userPreference',
|
||||
rowId: pref.id,
|
||||
field,
|
||||
reason: 'model field is not provider::modelId',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (!pref.customModels) continue
|
||||
let parsedCustomModels
|
||||
try {
|
||||
parsedCustomModels = JSON.parse(pref.customModels)
|
||||
} catch {
|
||||
summary.userPreference.invalidCustomModelsJson += 1
|
||||
addSample(summary, {
|
||||
table: 'userPreference',
|
||||
rowId: pref.id,
|
||||
field: 'customModels',
|
||||
reason: 'invalid JSON',
|
||||
})
|
||||
continue
|
||||
}
|
||||
if (!Array.isArray(parsedCustomModels)) {
|
||||
summary.userPreference.invalidCustomModelsJson += 1
|
||||
addSample(summary, {
|
||||
table: 'userPreference',
|
||||
rowId: pref.id,
|
||||
field: 'customModels',
|
||||
reason: 'customModels is not array',
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
for (let index = 0; index < parsedCustomModels.length; index += 1) {
|
||||
const modelRaw = parsedCustomModels[index]
|
||||
if (!isRecord(modelRaw)) {
|
||||
summary.userPreference.invalidCustomModelShape += 1
|
||||
addSample(summary, {
|
||||
table: 'userPreference',
|
||||
rowId: pref.id,
|
||||
field: `customModels[${index}]`,
|
||||
reason: 'model item is not object',
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
const modelKey = isNonEmptyString(modelRaw.modelKey) ? modelRaw.modelKey.trim() : ''
|
||||
const provider = isNonEmptyString(modelRaw.provider) ? modelRaw.provider.trim() : ''
|
||||
const modelId = isNonEmptyString(modelRaw.modelId) ? modelRaw.modelId.trim() : ''
|
||||
const parsed = parseModelKeyStrict(modelKey)
|
||||
if (!parsed || parsed.provider !== provider || parsed.modelId !== modelId) {
|
||||
summary.userPreference.invalidCustomModelShape += 1
|
||||
addSample(summary, {
|
||||
table: 'userPreference',
|
||||
rowId: pref.id,
|
||||
field: `customModels[${index}].modelKey`,
|
||||
reason: 'modelKey/provider/modelId mismatch',
|
||||
})
|
||||
}
|
||||
|
||||
const modelType = isNonEmptyString(modelRaw.type) ? modelRaw.type.trim() : ''
|
||||
const capabilityIssues = validateCapabilities(modelType, modelRaw.capabilities)
|
||||
if (capabilityIssues.length > 0) {
|
||||
summary.userPreference.invalidCapabilities += 1
|
||||
addSample(summary, {
|
||||
table: 'userPreference',
|
||||
rowId: pref.id,
|
||||
field: capabilityIssues[0].field,
|
||||
reason: capabilityIssues[0].message,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const projects = await prisma.novelPromotionProject.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
analysisModel: true,
|
||||
characterModel: true,
|
||||
locationModel: true,
|
||||
storyboardModel: true,
|
||||
editModel: true,
|
||||
videoModel: true,
|
||||
},
|
||||
})
|
||||
|
||||
for (const project of projects) {
|
||||
summary.novelPromotionProject.total += 1
|
||||
for (const field of MODEL_FIELDS) {
|
||||
const rawValue = project[field]
|
||||
if (!rawValue) continue
|
||||
if (!parseModelKeyStrict(rawValue)) {
|
||||
summary.novelPromotionProject.invalidModelKeyFields += 1
|
||||
addSample(summary, {
|
||||
table: 'novelPromotionProject',
|
||||
rowId: project.id,
|
||||
field,
|
||||
reason: 'model field is not provider::modelId',
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
process.stdout.write(`${JSON.stringify(summary, null, 2)}\n`)
|
||||
|
||||
if (!STRICT) return
|
||||
const hasViolations = summary.userPreference.invalidModelKeyFields > 0
|
||||
|| summary.userPreference.invalidCustomModelsJson > 0
|
||||
|| summary.userPreference.invalidCustomModelShape > 0
|
||||
|| summary.userPreference.invalidCapabilities > 0
|
||||
|| summary.novelPromotionProject.invalidModelKeyFields > 0
|
||||
|
||||
if (hasViolations) {
|
||||
process.exitCode = 1
|
||||
}
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((error) => {
|
||||
process.stderr.write(`[check-model-config-contract] failed: ${String(error)}\n`)
|
||||
process.exitCode = 1
|
||||
})
|
||||
.finally(async () => {
|
||||
if (prisma) {
|
||||
await prisma.$disconnect()
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user