diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml new file mode 100644 index 0000000..dbaa646 --- /dev/null +++ b/.github/workflows/validate.yml @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..aafcb34 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +.DS_Store +*.log diff --git a/README.md b/README.md new file mode 100644 index 0000000..b3f19b9 --- /dev/null +++ b/README.md @@ -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/.json`、`compute/coding-plans/.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)双重校验 diff --git a/__tests__/validate.test.mjs b/__tests__/validate.test.mjs new file mode 100644 index 0000000..741ac68 --- /dev/null +++ b/__tests__/validate.test.mjs @@ -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) + }) +}) diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..ac4deb3 --- /dev/null +++ b/package-lock.json @@ -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" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..6572160 --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/schemas/manifest.schema.json b/schemas/manifest.schema.json new file mode 100644 index 0000000..a74bc57 --- /dev/null +++ b/schemas/manifest.schema.json @@ -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 +} diff --git a/schemas/pricing.schema.json b/schemas/pricing.schema.json new file mode 100644 index 0000000..1bdaa97 --- /dev/null +++ b/schemas/pricing.schema.json @@ -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 +} diff --git a/schemas/provider.schema.json b/schemas/provider.schema.json new file mode 100644 index 0000000..7939721 --- /dev/null +++ b/schemas/provider.schema.json @@ -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 + } + } +} diff --git a/schemas/providers-index.schema.json b/schemas/providers-index.schema.json new file mode 100644 index 0000000..0170b0a --- /dev/null +++ b/schemas/providers-index.schema.json @@ -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 +} diff --git a/schemas/service-map.schema.json b/schemas/service-map.schema.json new file mode 100644 index 0000000..b41abaa --- /dev/null +++ b/schemas/service-map.schema.json @@ -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 + } +} diff --git a/scripts/validate.mjs b/scripts/validate.mjs new file mode 100644 index 0000000..63247e6 --- /dev/null +++ b/scripts/validate.mjs @@ -0,0 +1,154 @@ +#!/usr/bin/env node +/** + * 校验 config-center 所有数据文件是否符合 frozen schema 契约。 + * + * 用法: + * node scripts/validate.mjs # 校验整个仓库 + * node scripts/validate.mjs --file

# 校验单个文件 + * + * 退出码: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() +}