feat(catalog): 将官方模型目录迁入公开Git仓库 #11

Merged
wangbo merged 1 commits from codex/git-model-catalog-20261015 into main 2026-10-15 11:57:21 +08:00
15 changed files with 30405 additions and 0 deletions
Showing only changes of commit 9547a027a2 - Show all commits
+4
View File
@@ -746,3 +746,7 @@ apt remove docker-compose
```bash
cd ~/easyai && cp docker-compose.yml docker-compose.yml.backup && wget -q https://git.51easyai.com/wangbo/easyai/raw/main/docker-compose.yml -O docker-compose.yml && echo "✅ docker-compose.yml 更新完成"
```
## 官方模型目录
官方基础模型定义由本仓库独立维护,无需重建后端即可通过 Git 发布。维护源、公开地址、版本准备、校验和回退流程见 [官方模型目录](model-catalog/official/README.md)。平台后端和独立 AI Gateway 只读消费;不再使用原 OSS/CDN 目录发布流程。
+2
View File
@@ -88,6 +88,8 @@ services:
environment:
# Node.js V8 old-space 堆上限(MiB),可在 .env 中覆盖
- NODE_OPTIONS=--max-old-space-size=${NODE_MAX_OLD_SPACE_SIZE:-8192}
# 官方目录由公开 Git 仓库维护,模型数据更新无需重新构建服务镜像。
- CONFIG_OFFICIAL_MODEL_CATALOG_URL=${CONFIG_OFFICIAL_MODEL_CATALOG_URL:-https://git.51easyai.com/wangbo/easyai/raw/branch/main/model-catalog/official/latest.json}
# MongoDB配置,用户管理,不会配置的话保持默认即可
- CONFIG_DB_MONGO_URI=mongodb://172.21.0.3:27017
- CONFIG_DB_MONGO_USERNAME=${MONGO_INITDB_ROOT_USERNAME}
+1
View File
@@ -0,0 +1 @@
node_modules/
+9
View File
@@ -0,0 +1,9 @@
# 目录维护与消费验收
1. 执行 `yarn format`、`yarn validate`、`yarn test`、`yarn prepare-release --note "变更说明"`、`yarn run check`。
2. 对照当前公开版本逐条核对模型身份和定义,确认新增、修改、下线范围;未知缺失必须停止。
3. PR 合入 `main` 后,免登录读取公开维护源、`latest.json` 及指针中的版本快照;重新计算摘要并核对 Git 提交内容。
4. 使用平台真实 HTTP 和隔离 MongoDB 验证同步、重复同步、恢复停用模型、本地覆盖、主动停用、隐藏、绑定和价格保留。
5. 独立网关用真实公开 HTTP 验证指针、同源 HTTPS、内容摘要和模型解析;自定义源仍可配置。
6. 回退测试恢复历史定义并生成新版本,保留所有已有快照与 Git 历史。
7. 普通应用发布不修改模型定义。迁移不删除客户绑定、价格、权限,也不自动发起付费模型任务。
+8
View File
@@ -0,0 +1,8 @@
# 官方模型目录维护规则
- 唯一维护源为本目录 `catalog.source.json`。平台后端和独立 AI Gateway 都是只读消费者,不在应用仓库另存维护源。
- 内容和版本指针通过 Git 提交发布,公开地址见 README。禁止使用旧 OSS/CDN 上传流程;应用镜像发布不自动授权修改模型定义或生命周期。
- 变更前同步最新主线,检查新增、修改和下线差异;执行格式、Schema、测试和版本一致性检查,将维护源、版本快照和 latest.json 一并提交。
- 恢复历史定义时生成新的回退版本;不覆盖历史快照,不强推改写 Git 历史。
- 正常下线保留原目录身份并设置 offline;缺失模型默认拒绝准备版本。
- 不将客户绑定、价格、权限、账号或密钥放入公开目录。
+50
View File
@@ -0,0 +1,50 @@
# EasyAI 官方模型目录
本目录是官方基础模型定义的唯一维护位置。平台后端与独立 AI Gateway 只读消费,不另存维护源。目录内容不包含客户密钥、账号、平台绑定、价格、权限或运行策略。
- 维护 JSON:`catalog.source.json`
- 公开维护源:[catalog.source.json](https://git.51easyai.com/wangbo/easyai/raw/branch/main/model-catalog/official/catalog.source.json)
- 消费端入口:[latest.json](https://git.51easyai.com/wangbo/easyai/raw/branch/main/model-catalog/official/latest.json)
- Git 历史:[目录提交记录](https://git.51easyai.com/wangbo/easyai/commits/branch/main/model-catalog/official)
公开地址免登录。只有进入 `main` 的 Git 内容才会对外生效;未提交文件、临时 JSON 和普通分支不会替换正式目录。旧 OSS/CDN 上传流程已停用。
## 修改和发布
使用 Node 22.22.0、Yarn 1.22.22,在最新主线创建工作分支:
```bash
cd model-catalog/official
yarn install --frozen-lockfile --ignore-scripts
# 编辑 catalog.source.json
yarn format
yarn validate
yarn test
yarn prepare-release --note "本次模型变更说明"
yarn run check
git diff
```
`prepare-release` 只生成 Git 文件,不上传、不推送,也不切换任何线上对象。它会对照当前 Git 提交的上一版目录,默认拒绝缺少已有模型的候选。新增、能力修改和下线分别审查;正常下线保留原 `catalogKey`,将 `status` 设为 `offline`,不要通过删记录表达退役。确需物理移除时,必须同时声明 `--allow-removals --removal-reason "具体原因"`,原因及移除项会进入版本快照。
确认差异后,将维护源、`latest.json` 和本次 `releases/<版本>/catalog.json` 一起提交,经 PR 合入 `main`:
```bash
git add catalog.source.json latest.json releases/
git commit -m 'feat(catalog): 说明本次模型变更'
git push -u origin HEAD
```
平台后端配置项为 `CONFIG_OFFICIAL_MODEL_CATALOG_URL`,独立网关为 `AI_GATEWAY_OFFICIAL_MODEL_CATALOG_URL`,均使用上面的 `latest.json` 地址。目录同步后还须验收模型状态、身份和本地绑定、价格、权限不变;收录模型不代表上游账号已经可用。
## 回退
使用 `git log -- catalog.source.json` 查看历史,恢复选定提交中的定义,再准备一个新版本:
```bash
git restore --source=<历史提交> -- catalog.source.json
yarn prepare-release --note "恢复指定历史提交的模型定义"
yarn run check
```
审查差异、测试、提交并合入 `main` 后生效。回退也必须审查模型移除;历史中没有的新模型不会被静默删除。保留所有历史版本快照,不覆盖旧文件,不强推或重置公开分支。Git 提交记录用于确定修改人、时间、评审和内容差异;版本快照记录内容摘要及上一版本。
+397
View File
@@ -0,0 +1,397 @@
import crypto from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import process from "node:process";
import { execFileSync } from "node:child_process";
import { createRequire } from "node:module";
import { fileURLToPath } from "node:url";
const require = createRequire(import.meta.url);
const Ajv = require("ajv");
const directory = path.dirname(fileURLToPath(import.meta.url));
const repository = path.resolve(directory, "../..");
const defaultSourcePath = path.join(directory, "catalog.source.json");
const schemaPath = path.join(directory, "catalog.schema.json");
export const publicBaseUrl =
"https://git.51easyai.com/wangbo/easyai/raw/branch/main/model-catalog/official";
const args = process.argv.slice(2);
const option = (name) => {
const index = args.indexOf("--" + name);
return index < 0 ? "" : String(args[index + 1] || "");
};
const stableValue = (value) => {
if (Array.isArray(value)) return value.map(stableValue);
if (value && typeof value === "object") {
return Object.keys(value)
.sort()
.reduce((result, key) => {
result[key] = stableValue(value[key]);
return result;
}, {});
}
return value;
};
const checksum = (payload) =>
crypto
.createHash("sha256")
.update(JSON.stringify(stableValue(payload)))
.digest("hex");
const readJson = (file) => JSON.parse(fs.readFileSync(file, "utf8"));
const serializeJson = (value) => `${JSON.stringify(value, null, 2)}\n`;
function canonicalizeSource(source) {
return {
...source,
baseModels: source.baseModels.map((model) => {
const current = model.capabilities || {};
const capabilityKeys = [
...model.modelTypes,
...Object.keys(current).filter(
(key) => !model.modelTypes.includes(key),
),
];
const capabilities = Object.fromEntries(
capabilityKeys.map((type) => {
const { type: _ignoredType, ...config } = current[type] || {};
return [type, { type, ...config }];
}),
);
return { ...model, capabilities };
}),
};
}
function assertOrderedRanges(value, pathParts = []) {
if (Array.isArray(value)) {
const field = pathParts.at(-1) || "";
if (
(field.endsWith("_range") || field === "thinkingBudgetTokensRange") &&
value.length === 2 &&
value.every((item) => typeof item === "number") &&
value[0] > value[1]
) {
throw new Error(`范围上下限颠倒: ${pathParts.join(".")}`);
}
value.forEach((item, index) =>
assertOrderedRanges(item, [...pathParts, String(index)]),
);
return;
}
if (!value || typeof value !== "object") return;
if (
typeof value.min === "number" &&
typeof value.max === "number" &&
value.min > value.max
) {
throw new Error(`范围上下限颠倒: ${pathParts.join(".")}`);
}
Object.entries(value).forEach(([key, item]) =>
assertOrderedRanges(item, [...pathParts, key]),
);
}
export function validateSource(file = defaultSourcePath) {
const sourceText = fs.readFileSync(file, "utf8");
const source = JSON.parse(sourceText);
const ajv = new Ajv({ allErrors: true, strict: false });
const validate = ajv.compile(readJson(schemaPath));
if (!validate(source)) {
throw new Error(
`官方目录格式无效: ${ajv.errorsText(validate.errors, { separator: "; " })}`,
);
}
const providerCodes = new Set(source.providers.map((item) => item.code));
if (providerCodes.size !== source.providers.length) {
throw new Error("官方目录包含重复厂商 code");
}
const keys = new Set();
const identities = new Set();
for (const model of source.baseModels) {
if (keys.has(model.catalogKey))
throw new Error(`重复 catalogKey: ${model.catalogKey}`);
keys.add(model.catalogKey);
const identity = `${model.alias}\u0000${model.defaultRealModelName}`;
if (identities.has(identity))
throw new Error(
`重复模型身份: ${model.alias} / ${model.defaultRealModelName}`,
);
identities.add(identity);
if (model.providerCode && !providerCodes.has(model.providerCode)) {
throw new Error(
`模型 ${model.catalogKey} 引用了不存在的厂商 ${model.providerCode}`,
);
}
const capabilityKeys = Object.keys(model.capabilities);
const missingCapabilities = model.modelTypes.filter(
(type) => !capabilityKeys.includes(type),
);
const extraCapabilities = capabilityKeys.filter(
(type) => !model.modelTypes.includes(type),
);
if (missingCapabilities.length || extraCapabilities.length) {
throw new Error(
`模型 ${model.catalogKey} 的 modelTypes 与 capabilities 不一致` +
`(缺少: ${missingCapabilities.join(", ") || "无"};` +
`多余: ${extraCapabilities.join(", ") || "无"})`,
);
}
for (const [type, capability] of Object.entries(model.capabilities)) {
if (capability.type !== type) {
throw new Error(
`模型 ${model.catalogKey} 的能力 ${type}.type 必须等于 ${type}`,
);
}
}
const textToSpeech = model.capabilities.text_to_speech;
if (textToSpeech) {
const voices = Array.isArray(textToSpeech.voices)
? textToSpeech.voices
: [];
const voiceIds = voices.map((voice) => voice.id);
if (new Set(voiceIds).size !== voiceIds.length) {
throw new Error(`模型 ${model.catalogKey} 包含重复音色 ID`);
}
if (
textToSpeech.default_voice_id &&
!voiceIds.includes(textToSpeech.default_voice_id)
) {
throw new Error(
`模型 ${model.catalogKey} 的默认音色不在 voices 中: ${textToSpeech.default_voice_id}`,
);
}
}
if (model.catalogKey.startsWith("base:seed:")) {
if (!model.seedKey)
throw new Error(`种子模型 ${model.catalogKey} 必须包含 seedKey`);
} else if (model.seedKey !== undefined) {
throw new Error(`运行时模型 ${model.catalogKey} 不应包含 seedKey`);
}
assertOrderedRanges(model.capabilities, [model.catalogKey, "capabilities"]);
}
const canonicalText = serializeJson(canonicalizeSource(source));
if (sourceText !== canonicalText) {
throw new Error("官方目录格式不规范,请运行 yarn format 后再提交");
}
return source;
}
export function buildRelease(
source,
{ version, note = "", previousVersion = null } = {},
) {
const publishedAt = new Date().toISOString();
const selectedVersion = version || publishedAt.replace(/[-:.]/g, "");
if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,95}$/.test(selectedVersion))
throw new Error("目录版本格式无效");
const payload = {
schemaVersion: source.schemaVersion,
providers: source.providers,
baseModels: source.baseModels,
providerModelPresets: [],
};
return {
version: selectedVersion,
publishedAt,
generatedAt: publishedAt,
...payload,
checksum: checksum(payload),
count: source.baseModels.length,
ttlSeconds: 21600,
previousVersion,
sourceRepository: "https://git.51easyai.com/wangbo/easyai",
sourcePath: "model-catalog/official/catalog.source.json",
...(note ? { note } : {}),
};
}
export function assertNoMissingModels(
previous,
source,
{ allowRemovals = false, removalReason = "" } = {},
) {
const keys = new Set(source.baseModels.map((model) => model.catalogKey));
const removed = (previous?.baseModels || [])
.filter((model) => !keys.has(model.catalogKey))
.map((model) => ({ catalogKey: model.catalogKey, alias: model.alias }));
if (removed.length && (!allowRemovals || !removalReason.trim())) {
throw new Error(
`维护源遗漏已发布模型:${JSON.stringify(removed)}。下线请保留原身份并设置 status=offline;确需移除时须声明 --allow-removals --removal-reason。`,
);
}
return removed;
}
export function prepareArtifacts(targetDirectory = directory, options = {}) {
const source = validateSource(
path.join(targetDirectory, "catalog.source.json"),
);
const removed = assertNoMissingModels(options.previous, source, options);
const release = buildRelease(source, {
...options,
previousVersion: options.previous?.version || null,
});
if (removed.length)
release.removals = {
models: removed,
reason: options.removalReason.trim(),
};
const releasePath = path.join(
targetDirectory,
"releases",
release.version,
"catalog.json",
);
if (fs.existsSync(releasePath))
throw new Error("该版本快照已存在,请生成新版本,不能覆盖历史文件");
const pointer = {
schemaVersion: 1,
version: release.version,
checksum: release.checksum,
publishedAt: release.publishedAt,
catalogUrl: `${publicBaseUrl}/releases/${release.version}/catalog.json`,
};
fs.mkdirSync(path.dirname(releasePath), { recursive: true });
fs.writeFileSync(releasePath, serializeJson(release));
fs.writeFileSync(
path.join(targetDirectory, "latest.json"),
serializeJson(pointer),
);
return { pointer, release };
}
export function checkArtifacts(targetDirectory = directory) {
const source = validateSource(
path.join(targetDirectory, "catalog.source.json"),
);
const pointer = readJson(path.join(targetDirectory, "latest.json"));
if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,95}$/.test(pointer.version))
throw new Error("目录版本格式无效");
const release = readJson(
path.join(targetDirectory, "releases", pointer.version, "catalog.json"),
);
const payload = {
schemaVersion: source.schemaVersion,
providers: source.providers,
baseModels: source.baseModels,
providerModelPresets: [],
};
if (
pointer.version !== release.version ||
pointer.checksum !== release.checksum ||
release.checksum !== checksum(payload) ||
release.checksum !==
checksum({
schemaVersion: release.schemaVersion,
providers: release.providers,
baseModels: release.baseModels,
providerModelPresets: release.providerModelPresets || [],
})
) {
throw new Error(
"维护源、最新指针和版本快照不一致,请重新准备版本并一并提交",
);
}
if (
pointer.catalogUrl !==
`${publicBaseUrl}/releases/${release.version}/catalog.json`
)
throw new Error("目录快照必须指向 easyai 仓库公开地址");
return {
version: release.version,
checksum: release.checksum,
baseModelCount: release.baseModels.length,
catalogUrl: pointer.catalogUrl,
};
}
function previousCommittedRelease() {
const git = (file) =>
JSON.parse(
execFileSync("git", ["show", `HEAD:model-catalog/official/${file}`], {
cwd: repository,
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
maxBuffer: 4 * 1024 * 1024,
}),
);
let pointer;
try {
pointer = git("latest.json");
} catch (error) {
// A missing pointer is only allowed for this repository's first catalog commit.
try {
execFileSync(
"git",
["cat-file", "-e", "HEAD:model-catalog/official/latest.json"],
{ cwd: repository, stdio: "ignore" },
);
} catch {
return null;
}
throw error;
}
if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,95}$/.test(pointer.version))
throw new Error("已提交目录版本格式无效");
return git(`releases/${pointer.version}/catalog.json`);
}
function main() {
const command = args[0] || "validate";
if (command === "format") {
fs.writeFileSync(
defaultSourcePath,
serializeJson(canonicalizeSource(readJson(defaultSourcePath))),
);
validateSource();
console.log(JSON.stringify({ action: "formatted" }));
} else if (command === "validate") {
const source = validateSource();
console.log(
JSON.stringify({
action: "validated",
baseModelCount: source.baseModels.length,
providerCount: source.providers.length,
}),
);
} else if (command === "prepare") {
const result = prepareArtifacts(directory, {
previous: previousCommittedRelease(),
version: option("version") || undefined,
note: option("note"),
allowRemovals: args.includes("--allow-removals"),
removalReason: option("removal-reason"),
});
console.log(
JSON.stringify(
{
action: "prepared-for-git-commit",
version: result.pointer.version,
checksum: result.pointer.checksum,
baseModelCount: result.release.count,
instruction:
"检查 Git 差异后,将维护源、latest.json 和版本快照一并提交并推送;公开 JSON 只来自 Git 已提交内容。",
},
null,
2,
),
);
} else if (command === "check") {
console.log(JSON.stringify(checkArtifacts(), null, 2));
} else {
throw new Error(
"支持 format、validate、prepare、check;目录通过 Git 提交发布,不再使用 OSS/CDN 上传命令。",
);
}
}
if (
process.argv[1] &&
path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)
) {
try {
main();
} catch (error) {
console.error(error.message);
process.exitCode = 1;
}
}
+846
View File
@@ -0,0 +1,846 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "EasyAI Official Base Model Catalog Source",
"type": "object",
"required": ["schemaVersion", "providers", "baseModels"],
"additionalProperties": false,
"properties": {
"schemaVersion": { "const": 1 },
"providers": {
"type": "array",
"minItems": 1,
"items": { "$ref": "#/definitions/provider" }
},
"baseModels": {
"type": "array",
"minItems": 1,
"items": { "$ref": "#/definitions/baseModel" }
}
},
"definitions": {
"nonEmptyString": { "type": "string", "minLength": 1 },
"nonNegativeInteger": { "type": "integer", "minimum": 0 },
"positiveInteger": { "type": "integer", "minimum": 1 },
"stringList": {
"type": "array",
"items": { "type": "string", "minLength": 1 },
"uniqueItems": true
},
"numberList": {
"type": "array",
"items": { "type": "number" }
},
"integerList": {
"type": "array",
"items": { "type": "integer" },
"uniqueItems": true
},
"numberRange": {
"type": "array",
"items": { "type": "number" },
"minItems": 2,
"maxItems": 2
},
"numberRangeOrMap": {
"oneOf": [
{ "$ref": "#/definitions/numberRange" },
{
"type": "object",
"minProperties": 1,
"additionalProperties": { "$ref": "#/definitions/numberRange" }
}
]
},
"numberListOrMap": {
"oneOf": [
{ "$ref": "#/definitions/numberList" },
{
"type": "object",
"minProperties": 1,
"additionalProperties": { "$ref": "#/definitions/numberList" }
}
]
},
"stringListOrMap": {
"oneOf": [
{ "$ref": "#/definitions/stringList" },
{
"type": "object",
"minProperties": 1,
"additionalProperties": { "$ref": "#/definitions/stringList" }
}
]
},
"capabilityName": {
"enum": [
"audio_generate",
"audio_understanding",
"digital_human_generate",
"image_analysis",
"image_edit",
"image_generate",
"image_to_model",
"image_to_video",
"image_vectorize",
"mesh_edit",
"multiview_to_model",
"omni",
"omni_video",
"speech_to_text",
"text_embedding",
"text_generate",
"text_rerank",
"text_to_model",
"text_to_speech",
"tools_call",
"video_enhance",
"video_generate",
"video_understanding",
"voice_clone"
]
},
"provider": {
"type": "object",
"required": ["code", "name", "displayName"],
"properties": {
"code": { "$ref": "#/definitions/nonEmptyString" },
"name": { "$ref": "#/definitions/nonEmptyString" },
"displayName": { "$ref": "#/definitions/nonEmptyString" },
"iconPath": { "$ref": "#/definitions/nonEmptyString" }
},
"additionalProperties": false
},
"baseModel": {
"type": "object",
"required": [
"catalogKey",
"providerCode",
"displayName",
"alias",
"defaultRealModelName",
"modelTypes",
"capabilities",
"status"
],
"properties": {
"catalogKey": {
"type": "string",
"pattern": "^base:(runtime|seed):.+"
},
"seedKey": { "$ref": "#/definitions/nonEmptyString" },
"providerCode": { "$ref": "#/definitions/nonEmptyString" },
"displayName": { "$ref": "#/definitions/nonEmptyString" },
"alias": { "$ref": "#/definitions/nonEmptyString" },
"defaultRealModelName": { "$ref": "#/definitions/nonEmptyString" },
"modelTypes": {
"type": "array",
"minItems": 1,
"uniqueItems": true,
"items": { "$ref": "#/definitions/capabilityName" }
},
"iconPath": { "$ref": "#/definitions/nonEmptyString" },
"description": { "$ref": "#/definitions/nonEmptyString" },
"capabilities": { "$ref": "#/definitions/capabilities" },
"status": { "enum": ["active", "deprecated", "offline"] }
},
"additionalProperties": false
},
"languageCapability": {
"type": "object",
"required": ["type"],
"properties": {
"type": { "$ref": "#/definitions/capabilityName" },
"max_context_tokens": { "$ref": "#/definitions/nonNegativeInteger" },
"max_input_tokens": { "$ref": "#/definitions/nonNegativeInteger" },
"max_output_tokens": { "$ref": "#/definitions/nonNegativeInteger" },
"max_thinking_tokens": { "$ref": "#/definitions/nonNegativeInteger" },
"outputTokenLimitScope": { "enum": ["total", "answer_only"] },
"outputTokenParameter": {
"enum": ["max_tokens", "max_completion_tokens"]
},
"defaultOutputTokenBudget": { "const": "candidate_max" },
"supportThinking": { "type": "boolean" },
"supportThinkingModeSwitch": { "type": "boolean" },
"supportStructuredOutput": { "type": "boolean" },
"supportTool": { "type": "boolean" },
"supportWebSearch": { "type": "boolean" },
"thinkingBudgetTokensRange": { "$ref": "#/definitions/numberRange" },
"thinkingEffortLevels": { "$ref": "#/definitions/stringList" },
"supportedApiProtocols": { "$ref": "#/definitions/stringList" }
},
"additionalProperties": false
},
"textEmbeddingCapability": {
"type": "object",
"required": ["type"],
"properties": {
"type": { "const": "text_embedding" },
"dimensions": {
"type": "array",
"items": { "$ref": "#/definitions/positiveInteger" },
"uniqueItems": true
}
},
"additionalProperties": false
},
"textRerankCapability": {
"type": "object",
"required": ["type"],
"properties": {
"type": { "const": "text_rerank" },
"maxDocuments": { "$ref": "#/definitions/positiveInteger" },
"maxRequestTokens": { "$ref": "#/definitions/positiveInteger" },
"maxTokensPerDocument": { "$ref": "#/definitions/positiveInteger" },
"supportInstruct": { "type": "boolean" },
"supportTopN": { "type": "boolean" }
},
"additionalProperties": false
},
"inputParameter": {
"type": "object",
"required": ["type", "parameter"],
"properties": {
"type": { "$ref": "#/definitions/nonEmptyString" },
"parameter": { "$ref": "#/definitions/nonEmptyString" }
},
"additionalProperties": false
},
"imageCapability": {
"type": "object",
"required": ["type"],
"properties": {
"type": { "enum": ["image_generate", "image_edit"] },
"allow_custom_width_height_size": { "type": "boolean" },
"aspect_ratio_allowed": { "$ref": "#/definitions/stringList" },
"aspect_ratio_range": { "$ref": "#/definitions/numberRange" },
"input_format_allowed": { "$ref": "#/definitions/stringList" },
"input_max_file_size_bytes": {
"$ref": "#/definitions/nonNegativeInteger"
},
"input_max_images_count": {
"$ref": "#/definitions/nonNegativeInteger"
},
"input_multiple_images": { "type": "boolean" },
"input_output_max_images_count": {
"$ref": "#/definitions/nonNegativeInteger"
},
"input_parameters": {
"type": "array",
"items": { "$ref": "#/definitions/inputParameter" }
},
"input_size_range": { "$ref": "#/definitions/stringList" },
"output_max_images_count": {
"$ref": "#/definitions/nonNegativeInteger"
},
"output_max_size": { "$ref": "#/definitions/nonNegativeInteger" },
"output_multiple_images": { "type": "boolean" },
"output_resolutions": { "$ref": "#/definitions/stringList" },
"output_size_range": { "$ref": "#/definitions/numberRange" },
"quality_options": { "$ref": "#/definitions/stringList" },
"size_param_format": { "$ref": "#/definitions/nonEmptyString" },
"support_quality_control": { "type": "boolean" },
"width_height_multiple": { "$ref": "#/definitions/positiveInteger" },
"width_height_range": { "$ref": "#/definitions/numberRange" }
},
"additionalProperties": false
},
"promptLengthLimit": {
"type": "object",
"required": ["max"],
"properties": {
"max": { "$ref": "#/definitions/positiveInteger" },
"count_mode": { "$ref": "#/definitions/nonEmptyString" },
"label": { "$ref": "#/definitions/nonEmptyString" }
},
"additionalProperties": false
},
"resolutionRoute": {
"type": "object",
"required": [
"mode",
"generation_resolution",
"target_resolution",
"display_resolution",
"enhance_model",
"fallback_policy",
"billing_mode"
],
"properties": {
"mode": { "$ref": "#/definitions/nonEmptyString" },
"generation_resolution": { "$ref": "#/definitions/nonEmptyString" },
"target_resolution": { "$ref": "#/definitions/nonEmptyString" },
"display_resolution": { "$ref": "#/definitions/nonEmptyString" },
"enhance_model": { "$ref": "#/definitions/nonEmptyString" },
"fallback_policy": { "$ref": "#/definitions/nonEmptyString" },
"billing_mode": { "$ref": "#/definitions/nonEmptyString" }
},
"additionalProperties": false
},
"resolutionRoutes": {
"type": "object",
"minProperties": 1,
"additionalProperties": { "$ref": "#/definitions/resolutionRoute" }
},
"modeConstraint": {
"type": "object",
"properties": {
"aspect_ratio_allowed": { "$ref": "#/definitions/stringList" },
"forced_aspect_ratio": { "$ref": "#/definitions/nonEmptyString" },
"prompt_required": { "type": "boolean" }
},
"additionalProperties": false
},
"modeConstraints": {
"type": "object",
"minProperties": 1,
"additionalProperties": { "$ref": "#/definitions/modeConstraint" }
},
"mediaConstraint": {
"type": "object",
"properties": {
"formats": { "$ref": "#/definitions/stringList" },
"codecs": { "$ref": "#/definitions/stringList" },
"max_size_mb": { "type": "number", "minimum": 0 },
"max_count": { "$ref": "#/definitions/nonNegativeInteger" },
"min_dimension": { "$ref": "#/definitions/nonNegativeInteger" },
"max_dimension": { "$ref": "#/definitions/nonNegativeInteger" },
"aspect_ratio_range": { "$ref": "#/definitions/numberRange" },
"duration_range": { "$ref": "#/definitions/numberRange" },
"max_total_duration_seconds": { "type": "number", "minimum": 0 },
"fps_range": { "$ref": "#/definitions/numberRange" },
"requires_visual_reference": { "type": "boolean" }
},
"additionalProperties": false
},
"inputMediaConstraints": {
"type": "object",
"required": ["max_request_size_mb"],
"properties": {
"max_request_size_mb": { "type": "number", "minimum": 0 },
"image": { "$ref": "#/definitions/mediaConstraint" },
"video": { "$ref": "#/definitions/mediaConstraint" },
"audio": { "$ref": "#/definitions/mediaConstraint" }
},
"additionalProperties": false
},
"providerOperations": {
"type": "object",
"properties": {
"context_ir": { "type": "boolean" },
"regeneration": {
"type": "object",
"required": [
"source_resolutions",
"target_resolutions",
"requires_audio",
"source_fps"
],
"properties": {
"source_resolutions": { "$ref": "#/definitions/stringList" },
"target_resolutions": { "$ref": "#/definitions/stringList" },
"requires_audio": { "type": "boolean" },
"source_fps": { "type": "number", "minimum": 0 }
},
"additionalProperties": false
}
},
"additionalProperties": false
},
"limitsWithVideo": {
"type": "object",
"properties": {
"max_images_and_elements": {
"$ref": "#/definitions/nonNegativeInteger"
},
"duration_options": { "$ref": "#/definitions/numberList" }
},
"additionalProperties": false
},
"videoDraftCapability": {
"type": "object",
"additionalProperties": false,
"required": [
"enabled",
"contract_version",
"draft_resolution",
"final_resolutions",
"validity_seconds",
"input_modes"
],
"properties": {
"enabled": {
"type": "boolean"
},
"contract_version": {
"enum": [
1,
2
]
},
"draft_resolution": {
"enum": [
"480p",
"720p"
]
},
"final_resolutions": {
"type": "array",
"minItems": 1,
"maxItems": 1,
"items": {
"enum": [
"1080p",
"1440p"
]
}
},
"validity_seconds": {
"type": "integer", "minimum": 0
},
"input_modes": {
"$ref": "#/definitions/stringList"
},
"finalization_protocol": {
"enum": [
"minimax_h3_media_context",
"easyai_result_reference"
]
}
},
"allOf": [
{
"if": {
"properties": {
"contract_version": {
"const": 1
}
}
},
"then": {
"properties": {
"validity_seconds": { "const": 604800 },
"draft_resolution": {
"const": "480p"
},
"final_resolutions": {
"items": {
"const": "1080p"
}
}
}
},
"else": {
"required": [
"finalization_protocol"
],
"properties": {
"draft_resolution": {
"const": "720p"
},
"final_resolutions": {
"items": {
"const": "1440p"
}
}
}
}
}
]
},
"videoCapability": {
"type": "object",
"required": ["type"],
"properties": {
"type": { "enum": ["video_generate", "image_to_video", "omni_video"] },
"draft_mode": { "$ref": "#/definitions/videoDraftCapability" },
"aspect_ratio_allowed": { "$ref": "#/definitions/stringListOrMap" },
"aspect_ratio_range": { "$ref": "#/definitions/numberRange" },
"duration_options": { "$ref": "#/definitions/numberListOrMap" },
"duration_range": { "$ref": "#/definitions/numberRangeOrMap" },
"input_audio": { "type": "boolean" },
"input_first_frame": { "type": "boolean" },
"input_first_last_frame": { "type": "boolean" },
"input_last_frame": { "type": "boolean" },
"input_media_constraints": {
"$ref": "#/definitions/inputMediaConstraints"
},
"input_reference_generate_multiple": { "type": "boolean" },
"input_reference_generate_single": { "type": "boolean" },
"input_size_limit": { "type": "number", "minimum": 0 },
"input_smart_multi_frame": { "type": "boolean" },
"input_width_height_range": { "$ref": "#/definitions/numberRange" },
"limits_with_video": { "$ref": "#/definitions/limitsWithVideo" },
"max_audios": { "$ref": "#/definitions/nonNegativeInteger" },
"max_elements": { "$ref": "#/definitions/nonNegativeInteger" },
"max_file_size_mb": { "type": "number", "minimum": 0 },
"max_images": { "$ref": "#/definitions/nonNegativeInteger" },
"max_images_and_elements": {
"$ref": "#/definitions/nonNegativeInteger"
},
"max_images_for_last_frame": {
"$ref": "#/definitions/nonNegativeInteger"
},
"max_videos": { "$ref": "#/definitions/nonNegativeInteger" },
"mode_constraints": { "$ref": "#/definitions/modeConstraints" },
"output_audio": { "type": "boolean" },
"output_audio_mode": { "$ref": "#/definitions/nonEmptyString" },
"output_bgm": { "type": "boolean" },
"output_resolutions": { "$ref": "#/definitions/stringListOrMap" },
"output_support_return_last_frame": { "type": "boolean" },
"output_video_continuation": { "type": "boolean" },
"prompt_length_limit": { "$ref": "#/definitions/promptLengthLimit" },
"provider_operations": { "$ref": "#/definitions/providerOperations" },
"resolution_routes": { "$ref": "#/definitions/resolutionRoutes" },
"size_allowed": { "$ref": "#/definitions/stringListOrMap" },
"smart_multi_frame_mode": { "$ref": "#/definitions/nonEmptyString" },
"smart_multi_frame_range": { "$ref": "#/definitions/numberRange" },
"support_instruction_edit": { "type": "boolean" },
"support_video_effect_template": { "type": "boolean" },
"support_video_effect_template_list": {
"$ref": "#/definitions/stringList"
},
"supported_modes": { "$ref": "#/definitions/stringList" },
"supports_portrait_asset_reference": { "type": "boolean" },
"supportWebSearch": { "type": "boolean" }
},
"additionalProperties": false
},
"faceLimitRange": {
"type": "object",
"required": ["min", "max"],
"properties": {
"min": { "$ref": "#/definitions/nonNegativeInteger" },
"max": { "$ref": "#/definitions/nonNegativeInteger" }
},
"additionalProperties": false
},
"faceLimitByGeometryQuality": {
"type": "object",
"minProperties": 1,
"additionalProperties": {
"type": "object",
"properties": {
"triangle": { "$ref": "#/definitions/nonNegativeInteger" },
"quad": { "$ref": "#/definitions/nonNegativeInteger" }
},
"additionalProperties": false
}
},
"faceLimitRangeByTopology": {
"type": "object",
"minProperties": 1,
"additionalProperties": { "$ref": "#/definitions/faceLimitRange" }
},
"crossProviderEdit": {
"type": "object",
"properties": {
"tripo3d_import_supported": { "type": "boolean" }
},
"additionalProperties": false
},
"multiviewInput": {
"type": "object",
"required": ["min_images", "max_images", "views"],
"properties": {
"min_images": { "$ref": "#/definitions/nonNegativeInteger" },
"max_images": { "$ref": "#/definitions/nonNegativeInteger" },
"views": {
"type": "array",
"items": {
"type": "object",
"required": ["type", "order"],
"properties": {
"type": { "$ref": "#/definitions/nonEmptyString" },
"required": { "type": "boolean" },
"order": { "$ref": "#/definitions/nonNegativeInteger" }
},
"additionalProperties": false
}
},
"file_constraints": {
"type": "object",
"properties": {
"allowed_mime_types": { "$ref": "#/definitions/stringList" },
"min_side": { "$ref": "#/definitions/nonNegativeInteger" },
"max_side": { "$ref": "#/definitions/nonNegativeInteger" },
"per_file_max_bytes": {
"$ref": "#/definitions/nonNegativeInteger"
},
"auxiliary_total_max_bytes": {
"$ref": "#/definitions/nonNegativeInteger"
}
},
"additionalProperties": false
}
},
"additionalProperties": false
},
"model3dCapability": {
"type": "object",
"required": ["type"],
"properties": {
"type": {
"enum": ["text_to_model", "image_to_model", "multiview_to_model"]
},
"cross_provider_edit": { "$ref": "#/definitions/crossProviderEdit" },
"default_geometry_instruct_mode": {
"$ref": "#/definitions/nonEmptyString"
},
"default_output_format": { "$ref": "#/definitions/nonEmptyString" },
"face_limit_by_geometry_quality": {
"$ref": "#/definitions/faceLimitByGeometryQuality"
},
"face_limit_range": { "$ref": "#/definitions/faceLimitRange" },
"face_limit_range_by_topology": {
"$ref": "#/definitions/faceLimitRangeByTopology"
},
"geometry_instruct_modes": { "$ref": "#/definitions/stringList" },
"geometry_quality_options": { "$ref": "#/definitions/stringList" },
"max_face_limit": { "$ref": "#/definitions/nonNegativeInteger" },
"max_face_limit_quad": { "$ref": "#/definitions/nonNegativeInteger" },
"multiview_input": { "$ref": "#/definitions/multiviewInput" },
"multiview_max_images": { "$ref": "#/definitions/nonNegativeInteger" },
"multiview_min_images": { "$ref": "#/definitions/nonNegativeInteger" },
"multiview_required_slots": { "$ref": "#/definitions/stringList" },
"output_formats": { "$ref": "#/definitions/stringList" },
"pbr_required_when_texture": { "type": "boolean" },
"provider_generation_only": { "type": "boolean" },
"support_export_uv": { "type": "boolean" },
"support_image_autofix": { "type": "boolean" },
"support_micro_detail": { "type": "boolean" },
"support_part_generation": { "type": "boolean" },
"support_pbr": { "type": "boolean" },
"support_public_image_url_only": { "type": "boolean" },
"support_quad": { "type": "boolean" },
"support_smart_low_poly": { "type": "boolean" },
"support_ta_pose": { "type": "boolean" },
"support_texture": { "type": "boolean" },
"support_texture_delight": { "type": "boolean" },
"texture_quality_options": { "$ref": "#/definitions/stringList" }
},
"additionalProperties": false
},
"meshEditCapability": {
"type": "object",
"required": ["type"],
"properties": {
"type": { "const": "mesh_edit" },
"supported_operations": {
"type": "object",
"minProperties": 1,
"additionalProperties": {
"type": "object",
"required": ["supported"],
"properties": {
"supported": { "type": "boolean" },
"supported_formats": { "$ref": "#/definitions/stringList" },
"supported_texture_prompt_modes": {
"$ref": "#/definitions/stringList"
}
},
"additionalProperties": false
}
}
},
"additionalProperties": false
},
"audioGenerateCapability": {
"type": "object",
"required": ["type"],
"properties": {
"type": { "const": "audio_generate" },
"output_formats": { "$ref": "#/definitions/stringList" },
"supports_instrumental": { "type": "boolean" },
"supports_lyrics": { "type": "boolean" },
"supports_lyrics_optimizer": { "type": "boolean" },
"supports_prompt": { "type": "boolean" }
},
"additionalProperties": false
},
"speechToTextCapability": {
"type": "object",
"required": ["type"],
"properties": {
"type": { "const": "speech_to_text" },
"endpoint_path": { "$ref": "#/definitions/nonEmptyString" },
"input_key": { "$ref": "#/definitions/nonEmptyString" },
"max_duration_seconds": { "type": "number", "minimum": 0 },
"max_file_size_mb": { "type": "number", "minimum": 0 },
"mode": { "$ref": "#/definitions/nonEmptyString" },
"poll_interval_ms": { "$ref": "#/definitions/nonNegativeInteger" },
"poll_timeout_ms": { "$ref": "#/definitions/nonNegativeInteger" },
"query_url": { "$ref": "#/definitions/nonEmptyString" },
"resource_id": { "$ref": "#/definitions/nonEmptyString" },
"submit_url": { "$ref": "#/definitions/nonEmptyString" },
"supported_formats": { "$ref": "#/definitions/stringList" },
"supports_diarization": { "type": "boolean" },
"supports_emotion": { "type": "boolean" },
"supports_hotwords": { "type": "boolean" },
"supports_prompt_context": { "type": "boolean" },
"supports_timestamp": { "type": "boolean" }
},
"additionalProperties": false
},
"voice": {
"type": "object",
"required": ["id", "name"],
"properties": {
"accent": { "type": "string" },
"age": { "type": "string" },
"audio": { "type": "string" },
"desc": { "type": "string" },
"gender": { "type": "string" },
"icon": { "type": "string" },
"id": { "$ref": "#/definitions/nonEmptyString" },
"lang": { "type": "string" },
"name": { "$ref": "#/definitions/nonEmptyString" },
"provider": { "$ref": "#/definitions/nonEmptyString" },
"tags": { "$ref": "#/definitions/stringList" }
},
"additionalProperties": false
},
"ttsParamsCapability": {
"type": "object",
"properties": {
"output_audio": { "type": "boolean" },
"voice_modify": { "type": "boolean" },
"emotion": { "type": "boolean" },
"pronunciation_dict": { "type": "boolean" },
"language_boost": { "type": "boolean" },
"language_controls": { "type": "boolean" },
"text_filters": { "type": "boolean" },
"voice_instruction": { "type": "boolean" },
"audio_formats": { "$ref": "#/definitions/stringList" },
"sample_rates": { "$ref": "#/definitions/integerList" },
"opus_sample_rates": { "$ref": "#/definitions/integerList" },
"bit_rates": { "$ref": "#/definitions/integerList" },
"volume_range": {
"type": "object",
"required": ["min", "max", "step"],
"properties": {
"min": { "type": "number", "minimum": 0 },
"max": { "type": "number", "minimum": 0 },
"step": { "type": "number", "exclusiveMinimum": 0 }
},
"additionalProperties": false
}
},
"additionalProperties": false
},
"textToSpeechCapability": {
"type": "object",
"required": ["type"],
"properties": {
"type": { "const": "text_to_speech" },
"tts_params": {
"oneOf": [{ "const": false }, { "$ref": "#/definitions/ttsParamsCapability" }]
},
"voice_compatibility_group": {
"$ref": "#/definitions/nonEmptyString"
},
"default_voice_id": { "$ref": "#/definitions/nonEmptyString" },
"resource_id": { "$ref": "#/definitions/nonEmptyString" },
"voices": {
"type": "array",
"items": { "$ref": "#/definitions/voice" }
}
},
"additionalProperties": false
},
"audioInputConstraint": {
"type": "object",
"required": ["formats", "max_duration_seconds", "max_size_mb"],
"properties": {
"formats": { "$ref": "#/definitions/stringList" },
"min_duration_seconds": { "type": "number", "minimum": 0 },
"max_duration_seconds": { "type": "number", "minimum": 0 },
"max_size_mb": { "type": "number", "minimum": 0 }
},
"additionalProperties": false
},
"voiceCloneCapability": {
"type": "object",
"required": ["type"],
"properties": {
"type": { "const": "voice_clone" },
"preview_models": { "$ref": "#/definitions/stringList" },
"prompt_audio": { "$ref": "#/definitions/audioInputConstraint" },
"source_audio": { "$ref": "#/definitions/audioInputConstraint" }
},
"additionalProperties": false
},
"imageVectorizeCapability": {
"type": "object",
"required": ["type"],
"properties": {
"type": { "const": "image_vectorize" },
"async_task": { "type": "boolean" },
"cleanup_levels": { "$ref": "#/definitions/stringList" },
"input_format_allowed": { "$ref": "#/definitions/stringList" },
"max_colors_options": { "$ref": "#/definitions/integerList" },
"output_format_allowed": { "$ref": "#/definitions/stringList" },
"support_base64_input": { "type": "boolean" },
"support_url_input": { "type": "boolean" },
"supports_extra_format_download": { "type": "boolean" },
"supports_preview": { "type": "boolean" }
},
"additionalProperties": false
},
"videoEnhanceCapability": {
"type": "object",
"required": ["type"],
"properties": {
"type": { "const": "video_enhance" },
"async_task": { "type": "boolean" },
"input_resolutions": { "$ref": "#/definitions/stringList" },
"input_video_required": { "type": "boolean" },
"max_file_size_mb": { "type": "number", "minimum": 0 },
"operations": { "$ref": "#/definitions/stringList" },
"output_resolutions": { "$ref": "#/definitions/stringList" },
"preserve_audio": { "type": "boolean" }
},
"additionalProperties": false
},
"digitalHumanCapability": {
"type": "object",
"required": ["type"],
"properties": {
"type": { "const": "digital_human_generate" }
},
"additionalProperties": false
},
"capabilities": {
"type": "object",
"minProperties": 1,
"properties": {
"audio_generate": { "$ref": "#/definitions/audioGenerateCapability" },
"audio_understanding": { "$ref": "#/definitions/languageCapability" },
"digital_human_generate": {
"$ref": "#/definitions/digitalHumanCapability"
},
"image_analysis": { "$ref": "#/definitions/languageCapability" },
"image_edit": { "$ref": "#/definitions/imageCapability" },
"image_generate": { "$ref": "#/definitions/imageCapability" },
"image_to_model": { "$ref": "#/definitions/model3dCapability" },
"image_to_video": { "$ref": "#/definitions/videoCapability" },
"image_vectorize": { "$ref": "#/definitions/imageVectorizeCapability" },
"mesh_edit": { "$ref": "#/definitions/meshEditCapability" },
"multiview_to_model": { "$ref": "#/definitions/model3dCapability" },
"omni": { "$ref": "#/definitions/languageCapability" },
"omni_video": { "$ref": "#/definitions/videoCapability" },
"speech_to_text": { "$ref": "#/definitions/speechToTextCapability" },
"text_embedding": { "$ref": "#/definitions/textEmbeddingCapability" },
"text_generate": { "$ref": "#/definitions/languageCapability" },
"text_rerank": { "$ref": "#/definitions/textRerankCapability" },
"text_to_model": { "$ref": "#/definitions/model3dCapability" },
"text_to_speech": { "$ref": "#/definitions/textToSpeechCapability" },
"tools_call": { "$ref": "#/definitions/languageCapability" },
"video_enhance": { "$ref": "#/definitions/videoEnhanceCapability" },
"video_generate": { "$ref": "#/definitions/videoCapability" },
"video_understanding": { "$ref": "#/definitions/languageCapability" },
"voice_clone": { "$ref": "#/definitions/voiceCloneCapability" }
},
"additionalProperties": false
}
}
}
File diff suppressed because it is too large Load Diff
+197
View File
@@ -0,0 +1,197 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import test from "node:test";
import {
assertNoMissingModels,
buildRelease,
checkArtifacts,
prepareArtifacts,
publicBaseUrl,
validateSource,
} from "./catalog.mjs";
const directory = path.dirname(new URL(import.meta.url).pathname);
const source = JSON.parse(
fs.readFileSync(path.join(directory, "catalog.source.json"), "utf8"),
);
function fixture(t) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "easyai-git-catalog-"));
t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
const data = structuredClone(source);
data.baseModels = data.baseModels.slice(0, 2);
const save = () =>
fs.writeFileSync(
path.join(dir, "catalog.source.json"),
JSON.stringify(data, null, 2) + "\n",
);
save();
return { dir, data, save };
}
test("唯一维护源通过完整 Schema 和规范格式校验", () => {
const data = validateSource();
assert.ok(data.baseModels.length >= 220);
assert.equal(data.providers.length, 27);
});
test("Nano Banana 2.1 保留唯一原身份和图像能力", () => {
const rows = source.baseModels.filter(
(model) => model.defaultRealModelName === "gemini-nano-banana-2.1",
);
assert.equal(rows.length, 1);
assert.equal(
rows[0].catalogKey,
"base:runtime:google-gemini:Nano Banana 2.1:gemini-nano-banana-2.1",
);
assert.equal(rows[0].alias, "Nano Banana 2.1");
assert.equal(rows[0].status, "active");
assert.deepEqual(rows[0].modelTypes, ["image_generate", "image_edit"]);
assert.deepEqual(rows[0].capabilities.image_generate.output_resolutions, [
"1K",
"2K",
"4K",
]);
assert.equal(rows[0].capabilities.image_edit.input_max_images_count, 14);
});
test("重复模型身份拒绝保存", (t) => {
const { dir, data, save } = fixture(t);
data.baseModels.push(structuredClone(data.baseModels[0]));
save();
assert.throws(
() => validateSource(path.join(dir, "catalog.source.json")),
/重复/,
);
});
test("能力字段拼写和类型受 Schema 约束", (t) => {
const { dir, data, save } = fixture(t);
const type = data.baseModels[0].modelTypes[0];
data.baseModels[0].capabilities[type].misspelledCapability = true;
save();
assert.throws(
() => validateSource(path.join(dir, "catalog.source.json")),
/additional properties/,
);
});
test("准备版本时同步生成公开指针和校验和匹配的快照", (t) => {
const { dir } = fixture(t);
const { pointer } = prepareArtifacts(dir, { version: "test-v1" });
assert.equal(
pointer.catalogUrl,
publicBaseUrl + "/releases/test-v1/catalog.json",
);
assert.equal(checkArtifacts(dir).version, "test-v1");
});
test("只改维护源而未重新生成快照会被检查拒绝", (t) => {
const { dir, data, save } = fixture(t);
prepareArtifacts(dir, { version: "test-v1" });
data.baseModels[0].displayName = "修改后的名称";
save();
assert.throws(() => checkArtifacts(dir), /不一致/);
});
test("篡改版本快照会被拒绝", (t) => {
const { dir } = fixture(t);
prepareArtifacts(dir, { version: "test-v1" });
const file = path.join(dir, "releases/test-v1/catalog.json");
const release = JSON.parse(fs.readFileSync(file));
release.baseModels[0].displayName = "篡改";
fs.writeFileSync(file, JSON.stringify(release));
assert.throws(() => checkArtifacts(dir), /不一致/);
});
test("遗漏已发布模型默认拒绝,正常下线保留原身份", (t) => {
const { data } = fixture(t);
const previous = buildRelease(data, { version: "test-v1" });
const reduced = structuredClone(data);
reduced.baseModels.pop();
assert.throws(
() => assertNoMissingModels(previous, reduced),
/遗漏已发布模型/,
);
data.baseModels[0].status = "offline";
assert.deepEqual(assertNoMissingModels(previous, data), []);
});
test("物理移除必须同时声明允许和原因,并记录在新版本中", (t) => {
const { dir, data, save } = fixture(t);
const { release: previous } = prepareArtifacts(dir, { version: "test-v1" });
data.baseModels.pop();
save();
assert.throws(
() => prepareArtifacts(dir, { previous, allowRemovals: true }),
/遗漏/,
);
assert.throws(
() => prepareArtifacts(dir, { previous, removalReason: "迁移" }),
/遗漏/,
);
const { release } = prepareArtifacts(dir, {
previous,
version: "test-v2",
allowRemovals: true,
removalReason: "明确移除",
});
assert.equal(release.removals.models.length, 1);
assert.equal(release.removals.reason, "明确移除");
assert.equal(checkArtifacts(dir).version, "test-v2");
});
test("历史快照不能覆盖且版本不能包含目录穿越", (t) => {
const { dir } = fixture(t);
prepareArtifacts(dir, { version: "test-v1" });
assert.throws(
() => prepareArtifacts(dir, { version: "test-v1" }),
/不能覆盖/,
);
assert.throws(
() => prepareArtifacts(dir, { version: "../outside" }),
/版本格式/,
);
});
test("公开指针不能重新指向原 CDN 或其他仓库", (t) => {
const { dir } = fixture(t);
prepareArtifacts(dir, { version: "test-v1" });
const file = path.join(dir, "latest.json");
const pointer = JSON.parse(fs.readFileSync(file));
pointer.catalogUrl =
"https://aliyuncdn.51easyai.com/model-catalog/official/v1/catalog.json";
fs.writeFileSync(file, JSON.stringify(pointer));
assert.throws(() => checkArtifacts(dir), /easyai 仓库公开地址/);
});
test("恢复 Git 历史定义后生成新回退版本,保留全部快照", (t) => {
const { dir, data, save } = fixture(t);
const original = structuredClone(data);
const first = prepareArtifacts(dir, { version: "test-v1" });
data.baseModels[0].displayName = "新版";
save();
const second = prepareArtifacts(dir, {
previous: first.release,
version: "test-v2",
});
fs.writeFileSync(
path.join(dir, "catalog.source.json"),
JSON.stringify(original, null, 2) + "\n",
);
const third = prepareArtifacts(dir, {
previous: second.release,
version: "test-v3",
note: "恢复 test-v1 的定义",
});
assert.equal(third.release.previousVersion, "test-v2");
assert.equal(third.release.checksum, first.release.checksum);
assert.equal(checkArtifacts(dir).version, "test-v3");
assert.deepEqual(fs.readdirSync(path.join(dir, "releases")).sort(), [
"test-v1",
"test-v2",
"test-v3",
]);
});
+7
View File
@@ -0,0 +1,7 @@
{
"schemaVersion": 1,
"version": "20261015T034833896Z",
"checksum": "b196b44afb78ff349cc1384d0fb97d1c7d33198e8f95bbe0bd6e075dd8354e98",
"publishedAt": "2026-10-15T03:48:33.896Z",
"catalogUrl": "https://git.51easyai.com/wangbo/easyai/raw/branch/main/model-catalog/official/releases/20261015T034833896Z/catalog.json"
}
+20
View File
@@ -0,0 +1,20 @@
{
"name": "easyai-official-model-catalog",
"private": true,
"version": "1.0.0",
"type": "module",
"packageManager": "yarn@1.22.22",
"engines": {
"node": ">=22.22.0"
},
"scripts": {
"format": "node catalog.mjs format",
"validate": "node catalog.mjs validate",
"prepare-release": "node catalog.mjs prepare",
"check": "node catalog.mjs check",
"test": "node --test catalog.test.mjs"
},
"dependencies": {
"ajv": "8.17.1"
}
}
File diff suppressed because it is too large Load Diff
+33
View File
@@ -0,0 +1,33 @@
# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
# yarn lockfile v1
ajv@8.17.1:
version "8.17.1"
resolved "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz#37d9a5c776af6bc92d7f4f9510eba4c0a60d11a6"
integrity sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==
dependencies:
fast-deep-equal "^3.1.3"
fast-uri "^3.0.1"
json-schema-traverse "^1.0.0"
require-from-string "^2.0.2"
fast-deep-equal@^3.1.3:
version "3.1.3"
resolved "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525"
integrity sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==
fast-uri@^3.0.1:
version "3.1.8"
resolved "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz#f7db8d942e20eead3cfe93b0beeb4eb0169e938b"
integrity sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==
json-schema-traverse@^1.0.0:
version "1.0.0"
resolved "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz#ae7bcb3656ab77a73ba5c49bf654f38e6b6860e2"
integrity sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==
require-from-string@^2.0.2:
version "2.0.2"
resolved "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz#89a7fdd938261267318eafe14f9c32e598c36909"
integrity sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==
@@ -0,0 +1,2 @@
# 只补充缺失或空值,自定义目录地址仍由部署管理员控制。
CONFIG_OFFICIAL_MODEL_CATALOG_URL=https://git.51easyai.com/wangbo/easyai/raw/branch/main/model-catalog/official/latest.json