Merge pull request #3 from desirecore/feat/schema-validation

feat: frozen schema + CI validation to prevent client-breaking data
This commit is contained in:
2026-04-25 21:40:06 +08:00
committed by GitHub
12 changed files with 990 additions and 0 deletions

30
.github/workflows/validate.yml vendored Normal file
View File

@@ -0,0 +1,30 @@
name: Schema Validation
on:
pull_request:
branches: [main]
push:
branches: [main]
jobs:
validate:
name: Validate against frozen schema
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci || npm install
- name: Run validator
run: npm run validate
- name: Run unit tests
run: npm test

3
.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
node_modules/
.DS_Store
*.log

89
README.md Normal file
View File

@@ -0,0 +1,89 @@
# DesireCore Config Center
DesireCore 官方配置中心:托管 Provider / Model / Pricing / ServiceMap 数据,由
[desirecore](https://github.com/desirecore/desirecore) 客户端通过 `npm run sync-config-center`
脚本和运行时后台 fetch 拉取。
---
## 数据契约Frozen Schema
`schemas/` 目录下的 JSON Schema 是**已发布客户端的兼容契约**。所有写入此仓库的数据必须通过
`schemas/` 校验,否则会破坏老版本客户端。
### 历史背景
PR #1 曾把 reasoning 模型的 `defaultTemperature` / `defaultTopP` 写为 `null`
导致已发布客户端schema 严格 `number``readComputeConfig` 校验失败 → 同步路径死锁
→ 远程数据 revert 也救不了已污染的本地用户。详见 desirecore PR #471
为防止此类事故重演,本仓库引入 frozen schema + CI 自动校验。
### 校验规则
| Schema | 适用文件 | 关键约束 |
|--------|---------|---------|
| `provider.schema.json` | `compute/providers/*.json``compute/coding-plans/*.json` | `defaultTemperature`/`defaultTopP` 必须是 number禁止 null/string`additionalProperties: false` |
| `manifest.schema.json` | `manifest.json` | `presetDataVersion` 必须是递增整数 |
| `service-map.schema.json` | `compute/service-map.json` | 每条映射须含 `modelName` + `providerId` |
| `providers-index.schema.json` | 两个 `_index.json` | `order` 数组无重复 |
| `pricing.schema.json` | `compute/pricing.json` | `markupRatio` / `usdToCny` 为正数 |
### 关键规则reasoning 模型的温度参数
**禁止**`"defaultTemperature": null``"defaultTopP": null`
**正确做法**:完全省略字段。
```jsonc
// ❌ 错误:会破坏 fix #471 之前的客户端
{ "modelName": "deepseek-reasoner", "defaultTemperature": null }
// ✅ 正确reasoning 模型省略温度字段
{ "modelName": "deepseek-reasoner", "displayName": "DeepSeek Reasoner", ... }
```
### 关键规则:新增字段需先升级老客户端 schema
`provider``model` 顶层均启用 `additionalProperties: false`。如需新增字段:
1. 先在 desirecore 主仓 `lib/schemas/agent-service/compute.ts` 升级 schema 接受新字段
2. 发布新版本客户端
3. 等大部分用户升级
4. 再更新本仓库的 frozen schema 和数据
否则老客户端会因未知字段校验失败死锁。
---
## 本地校验
```bash
npm install
npm run validate # 校验所有数据文件
npm test # 跑单元测试(含反例测试)
```
CIGitHub Actions会在每个 PR 自动运行 `validate``test`,不通过禁止合并。
---
## 数据修改流程
1. 编辑 `compute/providers/<name>.json``compute/coding-plans/<name>.json``compute/service-map.json`
2. 编辑 `compute/providers/_index.json``coding-plans/_index.json`(新增/删除 provider 时)
3. **必须**递增 `manifest.json#presetDataVersion`,并更新 `updatedAt`
4. `npm run validate` 本地确认通过
5. 提 PR等 CI 校验通过
6. 合并到 main 后客户端会在下次后台 fetch最长 30 分钟)拾取更新
---
## 客户端拉取机制
详见 [desirecore CLAUDE.md](https://github.com/desirecore/desirecore/blob/main/CLAUDE.md)
"Config Center" 章节。
- **构建期同步**`npm run sync-config-center` 把数据复制到 desirecore 主仓 `lib/agent-service/defaults/`
- **运行时同步**:客户端启动后后台 git fetch 本仓库,每 30 分钟检查一次远程更新
- **版本比对**`presetDataVersion`(递增整数)+ digestSHA-256双重校验

300
__tests__/validate.test.mjs Normal file
View File

@@ -0,0 +1,300 @@
/**
* config-center frozen schema 校验测试
*
* 关键场景:
* - 现有所有数据文件必须通过 frozen schema 校验
* - PR #1 引发死锁的反例defaultTemperature: null必须被拒绝
* - 防御未知字段additionalProperties: false保护老客户端
*
* 运行:
* npm test
* node --test __tests__/
*/
import { describe, it } from 'node:test'
import { strict as assert } from 'node:assert'
import { readFileSync, readdirSync } from 'node:fs'
import { dirname, join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { Ajv } from 'ajv'
import addFormats from 'ajv-formats'
import { validateFile, loadSchemas } from '../scripts/validate.mjs'
const validators = loadSchemas()
const __dirname = dirname(fileURLToPath(import.meta.url))
const ROOT = resolve(__dirname, '..')
function loadSchema(name) {
const p = join(ROOT, 'schemas', `${name}.schema.json`)
return JSON.parse(readFileSync(p, 'utf8'))
}
function compile(name) {
const ajv = new Ajv({ allErrors: true, strict: false })
addFormats(ajv)
return ajv.compile(loadSchema(name))
}
// ==================== 真实数据 happy path ====================
describe('真实数据全量校验', () => {
it('所有 compute/providers/*.json 应通过 provider schema', () => {
const dir = join(ROOT, 'compute', 'providers')
const failures = []
for (const file of readdirSync(dir)) {
if (!file.endsWith('.json') || file === '_index.json') continue
const result = validateFile(join(dir, file), validators)
if (!result.ok) failures.push({ file, errors: result.errors })
}
assert.equal(
failures.length,
0,
`${failures.length} provider 文件校验失败:\n${JSON.stringify(failures, null, 2)}`,
)
})
it('所有 compute/coding-plans/*.json 应通过 provider schema', () => {
const dir = join(ROOT, 'compute', 'coding-plans')
const failures = []
for (const file of readdirSync(dir)) {
if (!file.endsWith('.json') || file === '_index.json') continue
const result = validateFile(join(dir, file), validators)
if (!result.ok) failures.push({ file, errors: result.errors })
}
assert.equal(
failures.length,
0,
`${failures.length} coding-plan 文件校验失败:\n${JSON.stringify(failures, null, 2)}`,
)
})
it('manifest.json 应通过 manifest schema', () => {
const result = validateFile(join(ROOT, 'manifest.json'), validators)
assert.equal(result.ok, true, JSON.stringify(result.errors, null, 2))
})
it('compute/pricing.json 应通过 pricing schema', () => {
const result = validateFile(join(ROOT, 'compute', 'pricing.json'), validators)
assert.equal(result.ok, true, JSON.stringify(result.errors, null, 2))
})
it('compute/service-map.json 应通过 service-map schema', () => {
const result = validateFile(join(ROOT, 'compute', 'service-map.json'), validators)
assert.equal(result.ok, true, JSON.stringify(result.errors, null, 2))
})
it('两个 _index.json 应通过 providers-index schema', () => {
const r1 = validateFile(join(ROOT, 'compute', 'providers', '_index.json'), validators)
const r2 = validateFile(join(ROOT, 'compute', 'coding-plans', '_index.json'), validators)
assert.equal(r1.ok, true, JSON.stringify(r1.errors, null, 2))
assert.equal(r2.ok, true, JSON.stringify(r2.errors, null, 2))
})
})
// ==================== Provider schema 反例 ====================
describe('provider schema 反例(防 PR #1 重演)', () => {
const validate = compile('provider')
function makeValidProvider() {
return {
id: 'provider-test-001',
provider: 'test',
label: 'Test',
baseUrl: 'https://api.test.com',
apiKeyRef: 'test',
apiKeyVerified: false,
enabled: false,
status: 'unconfigured',
services: ['chat'],
models: [
{
modelName: 'm1',
displayName: 'M1',
serviceType: ['chat'],
capabilities: ['chat'],
},
],
}
}
it('合法 minimal provider 通过', () => {
assert.equal(validate(makeValidProvider()), true, JSON.stringify(validate.errors))
})
it('拒绝 defaultTemperature: nullPR #1 死锁元凶)', () => {
const data = makeValidProvider()
data.models[0].defaultTemperature = null
assert.equal(validate(data), false)
assert.ok(
validate.errors.some((e) => e.instancePath.endsWith('/defaultTemperature')),
`应有 defaultTemperature 错误,实际:${JSON.stringify(validate.errors)}`,
)
})
it('拒绝 defaultTopP: null', () => {
const data = makeValidProvider()
data.models[0].defaultTopP = null
assert.equal(validate(data), false)
assert.ok(validate.errors.some((e) => e.instancePath.endsWith('/defaultTopP')))
})
it('拒绝 defaultTemperature: "0.7"string', () => {
const data = makeValidProvider()
data.models[0].defaultTemperature = '0.7'
assert.equal(validate(data), false)
})
it('接受省略 defaultTemperature/defaultTopPreasoning 模型推荐做法)', () => {
const data = makeValidProvider()
// 不设这两个字段
assert.equal(validate(data), true, JSON.stringify(validate.errors))
})
it('接受 defaultTemperature: 0.7(合法 number', () => {
const data = makeValidProvider()
data.models[0].defaultTemperature = 0.7
data.models[0].defaultTopP = 0.95
assert.equal(validate(data), true, JSON.stringify(validate.errors))
})
it('拒绝 defaultTemperature 超出 [0, 2] 范围', () => {
const data = makeValidProvider()
data.models[0].defaultTemperature = 3.0
assert.equal(validate(data), false)
})
it('拒绝 model 顶层未知字段additionalProperties: false 保护老客户端)', () => {
const data = makeValidProvider()
data.models[0].brandNewField = 'oops'
assert.equal(validate(data), false)
assert.ok(
validate.errors.some((e) => e.keyword === 'additionalProperties'),
`应有 additionalProperties 错误,实际:${JSON.stringify(validate.errors)}`,
)
})
it('拒绝 provider 顶层未知字段', () => {
const data = makeValidProvider()
data.unknownTopLevelField = 'oops'
assert.equal(validate(data), false)
assert.ok(validate.errors.some((e) => e.keyword === 'additionalProperties'))
})
it('拒绝缺少必填字段(如 model 缺 modelName', () => {
const data = makeValidProvider()
delete data.models[0].modelName
assert.equal(validate(data), false)
})
it('拒绝非法 status enum', () => {
const data = makeValidProvider()
data.status = 'wrong-status'
assert.equal(validate(data), false)
})
it('拒绝非法 priceCurrency enum仅允许 USD/CNY', () => {
const data = makeValidProvider()
data.priceCurrency = 'EUR'
assert.equal(validate(data), false)
})
})
// ==================== Manifest schema ====================
describe('manifest schema', () => {
const validate = compile('manifest')
it('合法 manifest 通过', () => {
assert.equal(
validate({
version: '1.0.0',
presetDataVersion: 30,
updatedAt: '2026-04-25',
description: 'test',
}),
true,
)
})
it('拒绝 presetDataVersion 为 string', () => {
assert.equal(
validate({
version: '1.0.0',
presetDataVersion: '30',
updatedAt: '2026-04-25',
}),
false,
)
})
it('拒绝非法 updatedAt 格式', () => {
assert.equal(
validate({
version: '1.0.0',
presetDataVersion: 30,
updatedAt: '04/25/2026',
}),
false,
)
})
it('拒绝缺少 presetDataVersion', () => {
assert.equal(
validate({ version: '1.0.0', updatedAt: '2026-04-25' }),
false,
)
})
})
// ==================== Service map schema ====================
describe('service-map schema', () => {
const validate = compile('service-map')
it('合法 service-map 通过', () => {
assert.equal(
validate({
chat: { modelName: 'gpt-5-mini', providerId: 'provider-openai-001' },
}),
true,
)
})
it('拒绝缺少 providerId 的条目', () => {
assert.equal(validate({ chat: { modelName: 'gpt-5-mini' } }), false)
})
it('拒绝条目中含未知字段', () => {
assert.equal(
validate({
chat: {
modelName: 'gpt-5-mini',
providerId: 'provider-openai-001',
extra: 'oops',
},
}),
false,
)
})
})
// ==================== Providers-index schema ====================
describe('providers-index schema', () => {
const validate = compile('providers-index')
it('合法 _index.json 通过', () => {
assert.equal(validate({ description: 'x', order: ['openai', 'anthropic'] }), true)
})
it('拒绝 order 含重复元素', () => {
assert.equal(validate({ order: ['openai', 'openai'] }), false)
})
it('拒绝缺少 order', () => {
assert.equal(validate({ description: 'x' }), false)
})
})

89
package-lock.json generated Normal file
View File

@@ -0,0 +1,89 @@
{
"name": "@desirecore/config-center",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@desirecore/config-center",
"version": "1.0.0",
"dependencies": {
"ajv": "^8.17.1",
"ajv-formats": "^3.0.1"
},
"engines": {
"node": ">=20"
}
},
"node_modules/ajv": {
"version": "8.20.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
"integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1",
"json-schema-traverse": "^1.0.0",
"require-from-string": "^2.0.2"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/epoberezkin"
}
},
"node_modules/ajv-formats": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz",
"integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==",
"license": "MIT",
"dependencies": {
"ajv": "^8.0.0"
},
"peerDependencies": {
"ajv": "^8.0.0"
},
"peerDependenciesMeta": {
"ajv": {
"optional": true
}
}
},
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"license": "MIT"
},
"node_modules/fast-uri": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz",
"integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "BSD-3-Clause"
},
"node_modules/json-schema-traverse": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
"license": "MIT"
},
"node_modules/require-from-string": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
}
}
}

18
package.json Normal file
View File

@@ -0,0 +1,18 @@
{
"name": "@desirecore/config-center",
"version": "1.0.0",
"private": true,
"description": "DesireCore 官方配置中心Provider/Model/Pricing/ServiceMap 数据源",
"type": "module",
"engines": {
"node": ">=20"
},
"scripts": {
"validate": "node scripts/validate.mjs",
"test": "node --test __tests__/*.test.mjs"
},
"dependencies": {
"ajv": "^8.17.1",
"ajv-formats": "^3.0.1"
}
}

View File

@@ -0,0 +1,30 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "https://desirecore.net/schemas/config-center/manifest.schema.json",
"title": "ConfigCenterManifest",
"description": "manifest.json 数据契约。客户端通过 presetDataVersion 判断是否合并更新。",
"type": "object",
"required": ["version", "presetDataVersion", "updatedAt"],
"properties": {
"version": {
"type": "string",
"description": "配置中心格式版本semver如 1.0.0",
"pattern": "^\\d+\\.\\d+\\.\\d+$"
},
"presetDataVersion": {
"type": "integer",
"description": "预置数据版本号(递增整数)。客户端运行时通过此字段决定是否合并新数据。每次内容变更必须递增。",
"minimum": 1
},
"updatedAt": {
"type": "string",
"description": "最后更新日期YYYY-MM-DD 格式",
"pattern": "^\\d{4}-\\d{2}-\\d{2}$"
},
"description": {
"type": "string",
"description": "仓库说明"
}
},
"additionalProperties": false
}

View File

@@ -0,0 +1,21 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "https://desirecore.net/schemas/config-center/pricing.schema.json",
"title": "Pricing",
"description": "compute/pricing.json定价相关元数据。",
"type": "object",
"required": ["markupRatio", "usdToCny"],
"properties": {
"markupRatio": {
"type": "number",
"description": "定价加成倍率(如 1.5 表示在原价基础上加 50%",
"exclusiveMinimum": 0
},
"usdToCny": {
"type": "number",
"description": "USD → CNY 汇率",
"exclusiveMinimum": 0
}
},
"additionalProperties": false
}

View File

@@ -0,0 +1,214 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "https://desirecore.net/schemas/config-center/provider.schema.json",
"title": "Provider",
"description": "frozen baseline schema 镜像 desirecore d185299fix #471 之前)的 computeProviderSchema/providerModelSchema作为已发布客户端兼容契约。任何写入 compute/providers/*.json 或 compute/coding-plans/*.json 的数据都必须通过此校验,否则会破坏老客户端(已发布版本的 schema 无法理解超出此契约的字段或类型)。\n\n关键约束\n- defaultTemperature / defaultTopP 必须是 number不接受 null—— 历史教训:曾因 null 写入导致老客户端校验失败死锁\n- 顶层与 model 内 additionalProperties 均为 false —— 新增字段必须先升级老客户端 schema 再推送数据",
"type": "object",
"required": [
"id",
"provider",
"label",
"baseUrl",
"apiKeyRef",
"apiKeyVerified",
"enabled",
"status",
"services",
"models"
],
"properties": {
"id": {
"type": "string",
"description": "提供商唯一标识符(如 provider-openai-001",
"minLength": 1
},
"provider": {
"type": "string",
"description": "提供商标识openai、anthropic、deepseek、dashscope阿里云等",
"minLength": 1
},
"label": {
"type": "string",
"description": "提供商显示名称(如 OpenAI、Anthropic、阿里云 DashScope",
"minLength": 1
},
"baseUrl": {
"type": "string",
"description": "API 基础 URL如 https://api.openai.com/v1",
"minLength": 1
},
"apiFormat": {
"type": "string",
"description": "API 协议格式openai-completions、anthropic-messages、openai-responses、google-generative-ai 等"
},
"apiKeyRef": {
"type": "string",
"description": "密钥引用名,对应 secrets.json 中的 key空字符串表示未配置密钥"
},
"apiKeyVerified": {
"type": "boolean",
"description": "API Key 是否已通过验证"
},
"enabled": {
"type": "boolean",
"description": "是否启用此提供商"
},
"status": {
"type": "string",
"enum": ["configured", "unconfigured", "error"],
"description": "提供商状态"
},
"services": {
"type": "array",
"description": "支持的服务类型列表(如 chat、reasoning、vision、embedding",
"items": { "type": "string" }
},
"priceCurrency": {
"type": "string",
"enum": ["USD", "CNY"],
"description": "价格货币单位。models 中的 inputPrice/outputPrice 均以此货币计价"
},
"accessMode": {
"type": "string",
"enum": ["api", "coding-plan"],
"description": "接入模式api按量付费或 coding-plan编程订阅套餐"
},
"brandGroup": {
"type": "string",
"description": "品牌分组标识UI 按此字段排序"
},
"codingPlan": {
"type": "object",
"description": "Coding Plan 专属配置(仅当 accessMode = coding-plan 时有效)",
"properties": {
"planTier": { "type": "string" },
"planLabel": { "type": "string" },
"quotas": {
"type": "object",
"properties": {
"per5h": { "type": "number", "minimum": 0 },
"perWeek": { "type": "number", "minimum": 0 },
"perMonth": { "type": "number", "minimum": 0 },
"per7d": { "type": "number", "minimum": 0 }
},
"additionalProperties": false
},
"usageTracking": {
"type": "object",
"properties": {
"method": {
"type": "string",
"enum": ["rest-api", "response-header", "manual", "none"]
},
"endpoint": { "type": "string" },
"headerKeys": {
"type": "object",
"properties": {
"remaining": { "type": "string" },
"limit": { "type": "string" },
"reset": { "type": "string" }
},
"additionalProperties": false
},
"consoleUrl": { "type": "string" }
},
"additionalProperties": false
},
"modelIdOverride": { "type": "string" },
"maxConcurrent": { "type": "number", "minimum": 1 },
"expiresAt": { "type": "string", "format": "date-time" }
},
"additionalProperties": false
},
"models": {
"type": "array",
"description": "此提供商下的可用模型列表",
"items": { "$ref": "#/definitions/model" }
},
"tombstones": {
"type": "array",
"description": "预置显式删除的模型 modelName 白名单",
"items": { "type": "string" }
}
},
"additionalProperties": false,
"definitions": {
"model": {
"type": "object",
"required": ["modelName", "displayName", "serviceType", "capabilities"],
"properties": {
"modelName": {
"type": "string",
"description": "模型 ID用于 API 调用(如 gpt-5-mini、claude-sonnet-4",
"minLength": 1
},
"displayName": {
"type": "string",
"description": "模型显示名称(如 GPT-5 Mini、Claude Sonnet 4",
"minLength": 1
},
"serviceType": {
"type": ["string", "array"],
"items": { "type": "string" },
"description": "服务类型支持单个字符串或数组chat、reasoning、fast、responses、translation、tts、asr、voice_clone、realtime_voice、simultaneous_interpret、vision、ocr、image_gen、video_gen、embedding、rerank、omni、computer_use 等"
},
"description": {
"type": "string",
"description": "模型简要描述"
},
"contextWindow": {
"type": "number",
"description": "上下文窗口大小token 数)",
"minimum": 0
},
"maxOutputTokens": {
"type": "number",
"description": "单次请求最大输出 token 数",
"minimum": 0
},
"capabilities": {
"type": "array",
"description": "模型能力标签chat、vision、tool_use、code、reasoning 等",
"items": { "type": "string" }
},
"inputPrice": {
"type": "number",
"description": "输入价格(每百万 token货币由 Provider.priceCurrency 决定",
"minimum": 0
},
"outputPrice": {
"type": "number",
"description": "输出价格(每百万 token货币由 Provider.priceCurrency 决定",
"minimum": 0
},
"defaultTemperature": {
"type": "number",
"description": "默认温度参数0-2。【重要】必须是 number禁止写为 null 或字符串。reasoning 等不支持温度的模型应完全省略此字段。null 会破坏 fix #471 之前发布的客户端schema 严格 number导致 readComputeConfig 死锁。",
"minimum": 0,
"maximum": 2
},
"defaultTopP": {
"type": "number",
"description": "默认 Top-P 参数0-1。【重要】必须是 number禁止写为 null 或字符串。reasoning 等不支持 Top-P 的模型应完全省略此字段。null 会破坏 fix #471 之前发布的客户端,导致死锁。",
"minimum": 0,
"maximum": 1
},
"extra": {
"type": "object",
"description": "模型特定配置:如 TTS 模型音色列表、ASR 支持格式等",
"additionalProperties": true
},
"apiModelId": {
"type": "string",
"description": "实际传给上游 API 的 model 参数。当 modelName 与 API 期望名称不同时使用"
},
"source": {
"type": "string",
"enum": ["preset", "synced", "user-added", "ollama-discovery"],
"description": "模型来源。config-center 数据应统一为 preset"
}
},
"additionalProperties": false
}
}
}

View File

@@ -0,0 +1,18 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "https://desirecore.net/schemas/config-center/providers-index.schema.json",
"title": "ProvidersIndex",
"description": "compute/providers/_index.json 与 compute/coding-plans/_index.json 共用。指定 Provider 加载顺序。",
"type": "object",
"required": ["order"],
"properties": {
"description": { "type": "string" },
"order": {
"type": "array",
"description": "Provider 加载顺序,元素为对应 JSON 文件的 basename不含 .json",
"items": { "type": "string", "minLength": 1 },
"uniqueItems": true
}
},
"additionalProperties": false
}

View File

@@ -0,0 +1,24 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "https://desirecore.net/schemas/config-center/service-map.schema.json",
"title": "ServiceMap",
"description": "compute/service-map.json默认服务到 (provider, model) 的映射。key 为服务类型chat/reasoning/...value 指向具体 Provider 和模型。",
"type": "object",
"additionalProperties": {
"type": "object",
"required": ["modelName", "providerId"],
"properties": {
"modelName": {
"type": "string",
"description": "目标模型 modelName须与对应 Provider 的 models[].modelName 一致",
"minLength": 1
},
"providerId": {
"type": "string",
"description": "目标 Provider 的 id 字段",
"minLength": 1
}
},
"additionalProperties": false
}
}

154
scripts/validate.mjs Normal file
View File

@@ -0,0 +1,154 @@
#!/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/providers/_index.json') return 'providers-index'
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'
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 }
}
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))
}
}
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()
}