mirror of
https://git.openapi.site/https://github.com/desirecore/config-center.git
synced 2026-06-06 04:30:51 +08:00
feat: frozen schema + CI validation to prevent client-breaking data
引入 frozen JSON Schema 契约(schemas/)和自动校验,作为已发布客户端的兼容防线。 背景: PR #1 把 reasoning 模型 defaultTemperature/defaultTopP 写为 null,已发布客户端 schema 严格 number → readComputeConfig 校验失败 → sync 死锁。详见 desirecore PR #471。 本次新增: - schemas/provider.schema.json: 镜像 desirecore d185299(fix #471 之前)的 strict computeProviderSchema/providerModelSchema,禁止 null/string/未知字段 - schemas/{manifest,service-map,pricing,providers-index}.schema.json: 配套契约 - scripts/validate.mjs: 扫所有数据文件自动校验 - __tests__/validate.test.mjs: 28 个测试,含 PR #1 反例的回归测试 - .github/workflows/validate.yml: PR/push 自动跑 validate + test 未来新增字段流程: 1. 先在 desirecore 主仓升级 schema 接受新字段 2. 发布新客户端,等用户升级 3. 再更新本仓库 frozen schema 和数据 否则老客户端因 additionalProperties: false 拒绝未知字段而死锁。
This commit is contained in:
30
.github/workflows/validate.yml
vendored
Normal file
30
.github/workflows/validate.yml
vendored
Normal 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
|
||||
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
package-lock.json
|
||||
.DS_Store
|
||||
*.log
|
||||
89
README.md
Normal file
89
README.md
Normal 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 # 跑单元测试(含反例测试)
|
||||
```
|
||||
|
||||
CI(GitHub 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`(递增整数)+ digest(SHA-256)双重校验
|
||||
300
__tests__/validate.test.mjs
Normal file
300
__tests__/validate.test.mjs
Normal 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: null(PR #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/defaultTopP(reasoning 模型推荐做法)', () => {
|
||||
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)
|
||||
})
|
||||
})
|
||||
18
package.json
Normal file
18
package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
30
schemas/manifest.schema.json
Normal file
30
schemas/manifest.schema.json
Normal 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
|
||||
}
|
||||
21
schemas/pricing.schema.json
Normal file
21
schemas/pricing.schema.json
Normal 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
|
||||
}
|
||||
214
schemas/provider.schema.json
Normal file
214
schemas/provider.schema.json
Normal 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 d185299(fix #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
|
||||
}
|
||||
}
|
||||
}
|
||||
18
schemas/providers-index.schema.json
Normal file
18
schemas/providers-index.schema.json
Normal 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
|
||||
}
|
||||
24
schemas/service-map.schema.json
Normal file
24
schemas/service-map.schema.json
Normal 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
154
scripts/validate.mjs
Normal 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 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/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,禁止写 null(reasoning 模型应完全省略字段)')
|
||||
console.error(' - additionalProperties 严格模式:新增字段需先在 desirecore 主仓升级 schema 再推送数据')
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
const isMain = process.argv[1] === fileURLToPath(import.meta.url)
|
||||
if (isMain) {
|
||||
main()
|
||||
}
|
||||
Reference in New Issue
Block a user