Files
config-center/scripts/validate.mjs
Yige c46cf07d2f feat(compute): 补 GLM-5.3 系列规格并标注 Ox Alpha 真身 (#81)
* feat(compute): 补 GLM-5.3 系列规格并标注 Ox Alpha 真身

Ox Alpha 是智谱 GLM-5.3-Flash 的匿名发布别名(canonical_slug
z-ai/glm-5.3-flash-20260826,2026-08-26 揭晓后已从 OpenRouter 列表下架)。

改动:
- model-specs/zhipu.json 新增 glm-5.3 与 glm-5.3-flash 正式规格。两者均
  强制思考(z.ai 文档:thinking.type 仅接受 enabled;OpenRouter models API:
  reasoning.mandatory=true,supported_efforts=[max,high,low]),故
  routing.reasoning.supportedModes 不含 off。按 #79 的教训只用 exact 匹配,
  避免 glm-5.3* 误吞 glm-5.3-flash。
- model-specs/stealth.json 的 ox-alpha 用 spec.extra.modelOrigin 标注真身。
  条目保留:云端算力可能仍以旧别名下发该模型。
- schemas/model-spec.schema.json 为 extra.thinkingOnly / thinkingDefault
  补正式定义与 description。此前这两个键无任何说明,客户端因此从未消费,
  用户选「关闭思考」即触发上游 400(见 desirecore#2307)。
- scripts/validate.mjs 新增静默失效键名巡检:extra 是开放对象,写错键名
  既不报错也无告警。当前巡出 17 处扁平 extra.reasoningEffort。刻意只告警
  不失败——存量取值需逐个核实各自接入面实际接受哪些 effort。

* docs(schema): 写明 extra.reasoning 与 thinkingOnly 的职责边界

provider schema 的 extra.reasoning 此前只有子字段 description、对象本身没有,
维护者看不出它与 model-spec 的 thinkingOnly 分别回答什么问题——issue
desirecore#2307 的误解正源于此。补上对象级说明:本键答「接入面接受哪些 effort
值」且只能写在 provider model;thinkingOnly 答「off 能不能用」、不声明深度档位。
2026-08-27 15:21:25 +08:00

226 lines
8.1 KiB
JavaScript
Raw 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.
#!/usr/bin/env node
/**
* 校验 config-center 所有数据文件是否符合 frozen schema 契约。
*
* 用法:
* node scripts/validate.mjs # 校验整个仓库
* node scripts/validate.mjs --file <p> # 校验单个文件
*
* 退出码0 = 全部通过1 = 至少一个文件校验失败。
*
* 设计:
* - frozen schema 镜像 desirecore d185299fix #471 之前)的严格 schema
* - 任何破坏老版本兼容的数据(如 defaultTemperature: null会被拒绝
* - CI on pull_request 自动跑此脚本,不通过则禁止合并
*/
import { readFileSync, readdirSync, statSync } from 'node:fs'
import { join, dirname, basename, relative, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { Ajv } from 'ajv'
import addFormats from 'ajv-formats'
const __dirname = dirname(fileURLToPath(import.meta.url))
const ROOT = resolve(__dirname, '..')
/** 加载 schemas/ 下所有 JSON Schema。每次调用使用独立的 Ajv 实例避免 $id 冲突。 */
export function loadSchemas() {
const ajv = new Ajv({ allErrors: true, strict: false })
addFormats(ajv)
const schemasDir = join(ROOT, 'schemas')
const map = {}
for (const file of readdirSync(schemasDir)) {
if (!file.endsWith('.schema.json')) continue
const schema = JSON.parse(readFileSync(join(schemasDir, file), 'utf8'))
map[basename(file, '.schema.json')] = ajv.compile(schema)
}
return map
}
/**
* 决定单个数据文件应使用哪个 schema。
* 返回 schema key 字符串,或 null 表示无需校验。
*/
function pickSchemaKey(absPath) {
const rel = relative(ROOT, absPath).replaceAll('\\', '/')
if (rel === 'manifest.json') return 'manifest'
if (rel === 'compute/pricing.json') return 'pricing'
if (rel === 'compute/service-map.json') return 'service-map'
if (rel === 'compute/model-specs/_index.json') return 'model-specs-index'
if (rel === 'compute/providers/_index.json') return 'providers-index'
if (rel === 'runtimes/recommended.json') return 'runtime-recommended'
if (rel === 'runtimes/versions-fallback.json') return 'runtime-versions-fallback'
if (rel === 'compute/coding-plans/_index.json') return 'providers-index'
if (rel.startsWith('compute/providers/') && rel.endsWith('.json')) return 'provider'
if (rel.startsWith('compute/coding-plans/') && rel.endsWith('.json')) return 'provider'
if (rel.startsWith('compute/model-specs/') && rel.endsWith('.json')) return 'model-spec'
// api-providers/<capability>/*.json声明式外部 API 供应商_index.json 复用 providers-index
if (rel.startsWith('api-providers/') && rel.endsWith('/_index.json')) return 'providers-index'
if (rel.startsWith('api-providers/') && rel.endsWith('.json')) return 'api-provider'
return null
}
const SKIP_DIRS = new Set(['node_modules', 'schemas', '__tests__', 'scripts'])
function* walkJsonFiles(dir) {
for (const entry of readdirSync(dir)) {
if (entry.startsWith('.') || SKIP_DIRS.has(entry)) continue
const full = join(dir, entry)
const st = statSync(full)
if (st.isDirectory()) {
yield* walkJsonFiles(full)
} else if (entry.endsWith('.json')) {
yield full
}
}
}
function formatErrors(errors) {
return errors
.map((e) => ` ${e.instancePath || '/'}: ${e.message}${
e.params && Object.keys(e.params).length ? ` (${JSON.stringify(e.params)})` : ''
}`)
.join('\n')
}
/** 校验单个文件,返回 { ok, schemaKey, errors? } */
export function validateFile(absPath, validators = loadSchemas()) {
const schemaKey = pickSchemaKey(absPath)
if (!schemaKey) return { ok: true, schemaKey: null, skipped: true }
const validator = validators[schemaKey]
if (!validator) {
return { ok: false, schemaKey, errors: [{ message: `schema '${schemaKey}' not found` }] }
}
let data
try {
data = JSON.parse(readFileSync(absPath, 'utf8'))
} catch (err) {
return { ok: false, schemaKey, errors: [{ message: `JSON parse error: ${err.message}` }] }
}
const valid = validator(data)
if (valid) return { ok: true, schemaKey }
return { ok: false, schemaKey, errors: validator.errors }
}
/**
* 已知会静默失效的 extra 键名。
*
* `extra` 是开放对象:写错键名既不会被 schema 拒绝,也没有任何运行时告警——数据看着
* 在那儿客户端却永远读不到。desirecore#2307 就是这么来的model-spec 的
* extra.thinkingOnly 写下后零消费provider 侧另有 10 处扁平 extra.reasoningEffort
* 同样从未生效,直到用户撞上一个上游 400 才暴露。
*
* 刻意只警告、不失败:存量条目的正确取值必须逐个核实各自接入面实际接受哪些 effort
* 一次批量改写的风险远大于收益(声明过窄会削掉模型能力,漏写 none 会让原本能关思考
* 的模型失去该选项)。
*/
const SUSPICIOUS_EXTRA_KEYS = {
provider: {
reasoningEffort: '客户端只读嵌套的 extra.reasoning.supportedEfforts扁平写法永不生效',
defaultReasoningEffort: '同上,应并入 extra.reasoning.defaultEffort',
},
spec: {
reasoningEffort: 'reasoning effort 是接入面能力,只能声明在 provider model 的 extra.reasoning 中',
},
}
/** 巡检静默失效键名,返回告警列表(不影响退出码)。 */
function lintSuspiciousExtraKeys(targets) {
const warnings = []
for (const file of targets) {
const rel = relative(ROOT, file)
let data
try {
data = JSON.parse(readFileSync(file, 'utf8'))
} catch {
continue // 解析失败由 schema 校验负责报错
}
if (Array.isArray(data.models)) {
for (const model of data.models) {
for (const [key, hint] of Object.entries(SUSPICIOUS_EXTRA_KEYS.provider)) {
if (model?.extra && Object.hasOwn(model.extra, key)) {
warnings.push(`${rel} → models[${model.modelName}].extra.${key}${hint}`)
}
}
}
}
if (Array.isArray(data.specs)) {
for (const entry of data.specs) {
const extra = entry?.spec?.extra
for (const [key, hint] of Object.entries(SUSPICIOUS_EXTRA_KEYS.spec)) {
if (extra && Object.hasOwn(extra, key)) {
warnings.push(`${rel} → specs[${entry.id}].spec.extra.${key}${hint}`)
}
}
}
}
}
return warnings
}
function main() {
const args = process.argv.slice(2)
const fileArgIdx = args.indexOf('--file')
const targets = []
if (fileArgIdx >= 0) {
const file = args[fileArgIdx + 1]
if (!file) {
console.error('--file requires a path argument')
process.exit(2)
}
targets.push(resolve(file))
} else {
targets.push(...walkJsonFiles(ROOT))
}
const validators = loadSchemas()
let failures = 0
let validated = 0
let skipped = 0
for (const file of targets) {
const rel = relative(ROOT, file)
const result = validateFile(file, validators)
if (result.skipped) {
skipped++
continue
}
if (result.ok) {
validated++
console.log(` ok [${result.schemaKey}] ${rel}`)
} else {
failures++
console.error(` fail [${result.schemaKey}] ${rel}`)
console.error(formatErrors(result.errors))
}
}
const warnings = lintSuspiciousExtraKeys(targets)
if (warnings.length > 0) {
console.log()
console.log(`静默失效键名告警(${warnings.length} 处,不影响校验结果):`)
for (const warning of warnings) console.log(` warn ${warning}`)
console.log(' 这些键写在 extra 里不会报错,但客户端从不读取;修正前请逐个核实该接入面实际接受的 effort。')
}
console.log()
console.log(`Summary: ${validated} validated, ${skipped} skipped, ${failures} failed`)
if (failures > 0) {
console.error('\n校验失败请检查上方错误。')
console.error('常见问题:')
console.error(' - defaultTemperature/defaultTopP 必须是 number禁止写 nullreasoning 模型应完全省略字段)')
console.error(' - additionalProperties 严格模式:新增字段需先在 desirecore 主仓升级 schema 再推送数据')
process.exit(1)
}
}
const isMain = process.argv[1] === fileURLToPath(import.meta.url)
if (isMain) {
main()
}