release: opensource snapshot 2026-02-27 19:25:00
This commit is contained in:
293
scripts/check-pricing-catalog.mjs
Normal file
293
scripts/check-pricing-catalog.mjs
Normal file
@@ -0,0 +1,293 @@
|
||||
import { promises as fs } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
const CATALOG_DIR = path.resolve(process.cwd(), 'standards/pricing')
|
||||
const CAPABILITY_CATALOG_FILE = path.resolve(process.cwd(), 'standards/capabilities/image-video.catalog.json')
|
||||
const API_TYPES = new Set(['text', 'image', 'video', 'voice', 'voice-design', 'lip-sync'])
|
||||
const PRICING_MODES = new Set(['flat', 'capability'])
|
||||
const TEXT_TOKEN_TYPES = new Set(['input', 'output'])
|
||||
|
||||
function isRecord(value) {
|
||||
return !!value && typeof value === 'object' && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function isNonEmptyString(value) {
|
||||
return typeof value === 'string' && value.trim().length > 0
|
||||
}
|
||||
|
||||
function isCapabilityValue(value) {
|
||||
return typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean'
|
||||
}
|
||||
|
||||
function isFiniteNumber(value) {
|
||||
return typeof value === 'number' && Number.isFinite(value)
|
||||
}
|
||||
|
||||
function pushIssue(issues, file, index, field, message) {
|
||||
issues.push({ file, index, field, message })
|
||||
}
|
||||
|
||||
function getProviderKey(providerId) {
|
||||
const marker = providerId.indexOf(':')
|
||||
return marker === -1 ? providerId : providerId.slice(0, marker)
|
||||
}
|
||||
|
||||
function buildModelKey(modelType, provider, modelId) {
|
||||
return `${modelType}::${provider}::${modelId}`
|
||||
}
|
||||
|
||||
async function listCatalogFiles() {
|
||||
const entries = await fs.readdir(CATALOG_DIR, { withFileTypes: true })
|
||||
return entries
|
||||
.filter((entry) => entry.isFile() && entry.name.endsWith('.json'))
|
||||
.map((entry) => path.join(CATALOG_DIR, entry.name))
|
||||
}
|
||||
|
||||
async function readCatalog(filePath) {
|
||||
const raw = await fs.readFile(filePath, 'utf8')
|
||||
const parsed = JSON.parse(raw)
|
||||
if (!Array.isArray(parsed)) {
|
||||
throw new Error(`catalog must be an array: ${filePath}`)
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
async function readCapabilityCatalog() {
|
||||
const raw = await fs.readFile(CAPABILITY_CATALOG_FILE, 'utf8')
|
||||
const parsed = JSON.parse(raw)
|
||||
if (!Array.isArray(parsed)) {
|
||||
throw new Error(`capability catalog must be an array: ${CAPABILITY_CATALOG_FILE}`)
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
function extractCapabilityOptionFields(modelType, capabilities) {
|
||||
if (!isRecord(capabilities)) return new Set()
|
||||
const namespace = capabilities[modelType]
|
||||
if (!isRecord(namespace)) return new Set()
|
||||
|
||||
const fields = new Set()
|
||||
for (const [key, value] of Object.entries(namespace)) {
|
||||
if (!key.endsWith('Options')) continue
|
||||
if (!Array.isArray(value) || value.length === 0) continue
|
||||
const field = key.slice(0, -'Options'.length)
|
||||
fields.add(field)
|
||||
}
|
||||
return fields
|
||||
}
|
||||
|
||||
function buildCapabilityOptionFieldMap(capabilityEntries) {
|
||||
const map = new Map()
|
||||
for (const entry of capabilityEntries) {
|
||||
if (!isRecord(entry)) continue
|
||||
const modelType = typeof entry.modelType === 'string' ? entry.modelType.trim() : ''
|
||||
const provider = typeof entry.provider === 'string' ? entry.provider.trim() : ''
|
||||
const modelId = typeof entry.modelId === 'string' ? entry.modelId.trim() : ''
|
||||
if (!modelType || !provider || !modelId) continue
|
||||
|
||||
const fields = extractCapabilityOptionFields(modelType, entry.capabilities)
|
||||
map.set(buildModelKey(modelType, provider, modelId), fields)
|
||||
const providerKey = getProviderKey(provider)
|
||||
const fallbackKey = buildModelKey(modelType, providerKey, modelId)
|
||||
if (!map.has(fallbackKey)) {
|
||||
map.set(fallbackKey, fields)
|
||||
}
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
function validateTier(issues, file, index, tier, tierIndex) {
|
||||
if (!isRecord(tier)) {
|
||||
pushIssue(issues, file, index, `pricing.tiers[${tierIndex}]`, 'tier must be object')
|
||||
return
|
||||
}
|
||||
|
||||
if (!isRecord(tier.when) || Object.keys(tier.when).length === 0) {
|
||||
pushIssue(issues, file, index, `pricing.tiers[${tierIndex}].when`, 'when must be non-empty object')
|
||||
} else {
|
||||
for (const [field, value] of Object.entries(tier.when)) {
|
||||
if (!isCapabilityValue(value)) {
|
||||
pushIssue(
|
||||
issues,
|
||||
file,
|
||||
index,
|
||||
`pricing.tiers[${tierIndex}].when.${field}`,
|
||||
'condition value must be string/number/boolean',
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!isFiniteNumber(tier.amount) || tier.amount < 0) {
|
||||
pushIssue(issues, file, index, `pricing.tiers[${tierIndex}].amount`, 'amount must be finite number >= 0')
|
||||
}
|
||||
}
|
||||
|
||||
function validateTextCapabilityTiers(issues, file, index, tiers) {
|
||||
const seenTokenTypes = new Set()
|
||||
|
||||
for (let tierIndex = 0; tierIndex < tiers.length; tierIndex += 1) {
|
||||
const tier = tiers[tierIndex]
|
||||
if (!isRecord(tier) || !isRecord(tier.when)) continue
|
||||
|
||||
const whenFields = Object.keys(tier.when)
|
||||
if (whenFields.length !== 1 || whenFields[0] !== 'tokenType') {
|
||||
pushIssue(issues, file, index, `pricing.tiers[${tierIndex}].when`, 'text capability tier must only contain tokenType')
|
||||
continue
|
||||
}
|
||||
|
||||
const tokenType = tier.when.tokenType
|
||||
if (typeof tokenType !== 'string' || !TEXT_TOKEN_TYPES.has(tokenType)) {
|
||||
pushIssue(issues, file, index, `pricing.tiers[${tierIndex}].when.tokenType`, 'tokenType must be input or output')
|
||||
continue
|
||||
}
|
||||
|
||||
if (seenTokenTypes.has(tokenType)) {
|
||||
pushIssue(issues, file, index, `pricing.tiers[${tierIndex}].when.tokenType`, `duplicate tokenType tier: ${tokenType}`)
|
||||
continue
|
||||
}
|
||||
seenTokenTypes.add(tokenType)
|
||||
}
|
||||
|
||||
for (const requiredTokenType of TEXT_TOKEN_TYPES) {
|
||||
if (!seenTokenTypes.has(requiredTokenType)) {
|
||||
pushIssue(issues, file, index, 'pricing.tiers', `missing text tier tokenType=${requiredTokenType}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function validateMediaCapabilityTierFields(issues, file, index, item, tiers, capabilityOptionFieldsMap) {
|
||||
const modelType = item.apiType
|
||||
const provider = item.provider
|
||||
const modelId = item.modelId
|
||||
const modelKey = buildModelKey(modelType, provider, modelId)
|
||||
const fallbackKey = buildModelKey(modelType, getProviderKey(provider), modelId)
|
||||
const optionFields = capabilityOptionFieldsMap.get(modelKey) || capabilityOptionFieldsMap.get(fallbackKey)
|
||||
|
||||
if (!optionFields || optionFields.size === 0) {
|
||||
pushIssue(issues, file, index, 'pricing.tiers', `no capability option fields found for ${modelType} ${provider}/${modelId}`)
|
||||
return
|
||||
}
|
||||
|
||||
for (let tierIndex = 0; tierIndex < tiers.length; tierIndex += 1) {
|
||||
const tier = tiers[tierIndex]
|
||||
if (!isRecord(tier) || !isRecord(tier.when)) continue
|
||||
for (const field of Object.keys(tier.when)) {
|
||||
if (!optionFields.has(field)) {
|
||||
pushIssue(
|
||||
issues,
|
||||
file,
|
||||
index,
|
||||
`pricing.tiers[${tierIndex}].when.${field}`,
|
||||
`field ${field} is not declared in capabilities options for ${modelType} ${provider}/${modelId}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function validateDuplicateCapabilityTiers(issues, file, index, tiers) {
|
||||
const seen = new Set()
|
||||
for (let tierIndex = 0; tierIndex < tiers.length; tierIndex += 1) {
|
||||
const tier = tiers[tierIndex]
|
||||
if (!isRecord(tier) || !isRecord(tier.when)) continue
|
||||
const signature = JSON.stringify(Object.entries(tier.when).sort((left, right) => left[0].localeCompare(right[0])))
|
||||
if (seen.has(signature)) {
|
||||
pushIssue(issues, file, index, `pricing.tiers[${tierIndex}].when`, 'duplicate capability tier condition')
|
||||
continue
|
||||
}
|
||||
seen.add(signature)
|
||||
}
|
||||
}
|
||||
|
||||
function validatePricing(issues, file, index, item, capabilityOptionFieldsMap) {
|
||||
const pricing = item.pricing
|
||||
if (!isRecord(pricing)) {
|
||||
pushIssue(issues, file, index, 'pricing', 'pricing must be object')
|
||||
return
|
||||
}
|
||||
|
||||
if (!isNonEmptyString(pricing.mode) || !PRICING_MODES.has(pricing.mode)) {
|
||||
pushIssue(issues, file, index, 'pricing.mode', 'pricing.mode must be flat or capability')
|
||||
return
|
||||
}
|
||||
|
||||
if (pricing.mode === 'flat') {
|
||||
if (!isFiniteNumber(pricing.flatAmount) || pricing.flatAmount < 0) {
|
||||
pushIssue(issues, file, index, 'pricing.flatAmount', 'flatAmount must be finite number >= 0')
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (!Array.isArray(pricing.tiers) || pricing.tiers.length === 0) {
|
||||
pushIssue(issues, file, index, 'pricing.tiers', 'tiers must be non-empty array')
|
||||
return
|
||||
}
|
||||
|
||||
for (let tierIndex = 0; tierIndex < pricing.tiers.length; tierIndex += 1) {
|
||||
validateTier(issues, file, index, pricing.tiers[tierIndex], tierIndex)
|
||||
}
|
||||
|
||||
validateDuplicateCapabilityTiers(issues, file, index, pricing.tiers)
|
||||
|
||||
if (item.apiType === 'text') {
|
||||
validateTextCapabilityTiers(issues, file, index, pricing.tiers)
|
||||
return
|
||||
}
|
||||
|
||||
if (item.apiType === 'image' || item.apiType === 'video') {
|
||||
validateMediaCapabilityTierFields(issues, file, index, item, pricing.tiers, capabilityOptionFieldsMap)
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const issues = []
|
||||
const files = await listCatalogFiles()
|
||||
const capabilityCatalog = await readCapabilityCatalog()
|
||||
const capabilityOptionFieldsMap = buildCapabilityOptionFieldMap(capabilityCatalog)
|
||||
if (files.length === 0) {
|
||||
throw new Error(`no pricing files found in ${CATALOG_DIR}`)
|
||||
}
|
||||
|
||||
for (const filePath of files) {
|
||||
const items = await readCatalog(filePath)
|
||||
for (let index = 0; index < items.length; index += 1) {
|
||||
const item = items[index]
|
||||
if (!isRecord(item)) {
|
||||
pushIssue(issues, filePath, index, 'entry', 'entry must be object')
|
||||
continue
|
||||
}
|
||||
|
||||
if (!isNonEmptyString(item.apiType) || !API_TYPES.has(item.apiType)) {
|
||||
pushIssue(issues, filePath, index, 'apiType', 'apiType must be one of text/image/video/voice/voice-design/lip-sync')
|
||||
}
|
||||
if (!isNonEmptyString(item.provider)) {
|
||||
pushIssue(issues, filePath, index, 'provider', 'provider must be non-empty string')
|
||||
}
|
||||
if (!isNonEmptyString(item.modelId)) {
|
||||
pushIssue(issues, filePath, index, 'modelId', 'modelId must be non-empty string')
|
||||
}
|
||||
|
||||
validatePricing(issues, filePath, index, item, capabilityOptionFieldsMap)
|
||||
}
|
||||
}
|
||||
|
||||
if (issues.length === 0) {
|
||||
process.stdout.write(`[check-pricing-catalog] OK (${files.length} files)\n`)
|
||||
return
|
||||
}
|
||||
|
||||
const maxPrint = 50
|
||||
for (const issue of issues.slice(0, maxPrint)) {
|
||||
process.stdout.write(`[check-pricing-catalog] ${issue.file}#${issue.index} ${issue.field}: ${issue.message}\n`)
|
||||
}
|
||||
if (issues.length > maxPrint) {
|
||||
process.stdout.write(`[check-pricing-catalog] ... ${issues.length - maxPrint} more issues\n`)
|
||||
}
|
||||
process.exitCode = 1
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
process.stderr.write(`[check-pricing-catalog] failed: ${String(error)}\n`)
|
||||
process.exitCode = 1
|
||||
})
|
||||
Reference in New Issue
Block a user