mirror of
https://git.openapi.site/https://github.com/desirecore/config-center.git
synced 2026-09-05 21:53:50 +08:00
* 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 能不能用」、不声明深度档位。
226 lines
8.1 KiB
JavaScript
226 lines
8.1 KiB
JavaScript
#!/usr/bin/env node
|
||
/**
|
||
* 校验 config-center 所有数据文件是否符合 frozen schema 契约。
|
||
*
|
||
* 用法:
|
||
* node scripts/validate.mjs # 校验整个仓库
|
||
* node scripts/validate.mjs --file <p> # 校验单个文件
|
||
*
|
||
* 退出码:0 = 全部通过,1 = 至少一个文件校验失败。
|
||
*
|
||
* 设计:
|
||
* - frozen schema 镜像 desirecore d185299(fix #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,禁止写 null(reasoning 模型应完全省略字段)')
|
||
console.error(' - additionalProperties 严格模式:新增字段需先在 desirecore 主仓升级 schema 再推送数据')
|
||
process.exit(1)
|
||
}
|
||
}
|
||
|
||
const isMain = process.argv[1] === fileURLToPath(import.meta.url)
|
||
if (isMain) {
|
||
main()
|
||
}
|