From fc15b61c818d963a3d04ad51b64ca2f744477d4f Mon Sep 17 00:00:00 2001 From: Victor Cao Date: Tue, 13 Oct 2026 11:16:51 +0800 Subject: [PATCH] =?UTF-8?q?=E5=88=9D=E5=A7=8B=E6=8F=90=E4=BA=A4=EF=BC=9A?= =?UTF-8?q?=E5=A4=A9=E5=9E=93150=E5=85=BC=E5=AE=B9=E5=BC=95=E6=93=8E=20I0?= =?UTF-8?q?=E2=80=93I4/I6/I7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitea/workflows/build.yml | 56 ++++++ .gitea/workflows/task_info.env | 3 + .gitignore | 8 + Dockerfile | 20 ++ Makefile | 9 + README.md | 67 +++++++ docs/evidence.md | 14 ++ docs/issues.md | 14 ++ docs/upload-runbook.md | 39 ++++ docs/weekly-report.md | 7 + patches/architectures/aliases.json | 1 + patches/architectures/whitelist.txt | 13 ++ .../chat_template/default_chat_template.jinja | 1 + patches/tokenizer/README.md | 5 + scripts/apply_patches.py | 187 ++++++++++++++++++ scripts/entrypoint.sh | 125 ++++++++++++ task_info.env.example | 12 ++ tests/__init__.py | 0 tests/fixtures/fake-model/config.json | 4 + tests/fixtures/fake-model/tokenizer.json | 3 + .../fixtures/fake-model/tokenizer_config.json | 3 + tests/test_contract.py | 96 +++++++++ tests/test_r2_chat_template.py | 67 +++++++ tests/test_r4_architectures.py | 65 ++++++ 24 files changed, 819 insertions(+) create mode 100644 .gitea/workflows/build.yml create mode 100644 .gitea/workflows/task_info.env create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 Makefile create mode 100644 README.md create mode 100644 docs/evidence.md create mode 100644 docs/issues.md create mode 100644 docs/upload-runbook.md create mode 100644 docs/weekly-report.md create mode 100644 patches/architectures/aliases.json create mode 100644 patches/architectures/whitelist.txt create mode 100644 patches/chat_template/default_chat_template.jinja create mode 100644 patches/tokenizer/README.md create mode 100755 scripts/apply_patches.py create mode 100755 scripts/entrypoint.sh create mode 100644 task_info.env.example create mode 100644 tests/__init__.py create mode 100644 tests/fixtures/fake-model/config.json create mode 100644 tests/fixtures/fake-model/tokenizer.json create mode 100644 tests/fixtures/fake-model/tokenizer_config.json create mode 100644 tests/test_contract.py create mode 100644 tests/test_r2_chat_template.py create mode 100644 tests/test_r4_architectures.py diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml new file mode 100644 index 0000000..1950122 --- /dev/null +++ b/.gitea/workflows/build.yml @@ -0,0 +1,56 @@ +name: Docker Build + +on: + push: + tags: + - "v*" + +jobs: + docker: + runs-on: amd64-ubuntu-24.04 + steps: + - name: Clone repository + run: | + git clone "${{ gitea.server_url }}/${{ gitea.repository }}.git" . + git checkout "${{ gitea.ref_name }}" + + - name: Load task info + run: | + set -a + . .gitea/workflows/task_info.env + set +a + echo "标签: ${{ gitea.ref_name }}" + echo "FRAMEWORK=${FRAMEWORK:-}" + echo "GPU_TYPE=${GPU_TYPE:-}" + echo "TASK_TYPE=${TASK_TYPE:-}" + if [ -z "${FRAMEWORK:-}" ] || [ "${FRAMEWORK}" = "vllm_fix_tokenizer" ]; then + echo "FRAMEWORK 无效。本仓使用 vllm_0_17_0_corex_4_4_0。" + exit 1 + fi + if [ "${GPU_TYPE:-}" != "Iluvatar_bi-150" ]; then + echo "GPU_TYPE 与本仓锁定值不一致: ${GPU_TYPE:-空}" + exit 1 + fi + IMAGE_NAME="$(echo "${{ gitea.repository }}" | tr '[:upper:]' '[:lower:]' | tr '_' '-')" + echo "IMAGE_NAME=${IMAGE_NAME}" >> "$GITEA_ENV" + echo "IMAGE_TAG=${{ gitea.ref_name }}" >> "$GITEA_ENV" + + - name: Build image + run: | + echo "开始 docker build,标签 ${IMAGE_TAG}" + docker build -t "${IMAGE_NAME}:${IMAGE_TAG}" . + echo "docker build 完成: ${IMAGE_NAME}:${IMAGE_TAG}" + + - name: Push when registry credentials exist + run: | + if [ -z "${DOCKER_REGISTRY:-}" ] || [ -z "${DOCKER_USERNAME:-}" ] || [ -z "${DOCKER_PASSWORD:-}" ]; then + echo "未配置 DOCKER_REGISTRY、DOCKER_USERNAME 或 DOCKER_PASSWORD。" + echo "本次为 build-only,不登录仓库,不推送镜像。" + exit 0 + fi + remote="${DOCKER_REGISTRY}/${DOCKER_USERNAME}/${IMAGE_NAME}:${IMAGE_TAG}" + echo "推送目标: ${remote}" + echo "$DOCKER_PASSWORD" | docker login "$DOCKER_REGISTRY" -u "$DOCKER_USERNAME" --password-stdin + docker tag "${IMAGE_NAME}:${IMAGE_TAG}" "$remote" + docker push "$remote" + echo "推送完成: ${remote}" diff --git a/.gitea/workflows/task_info.env b/.gitea/workflows/task_info.env new file mode 100644 index 0000000..6e5ef66 --- /dev/null +++ b/.gitea/workflows/task_info.env @@ -0,0 +1,3 @@ +GPU_TYPE=Iluvatar_bi-150 +FRAMEWORK=vllm_0_17_0_corex_4_4_0 +TASK_TYPE=text-generation diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..12f83cd --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +.env +.env.* +*.pem +*.key +secrets/ +__pycache__/ +*.pyc +.pytest_cache/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..1947173 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,20 @@ +# 基线 tag 来自公开仓反推,不是官方另行发布的锁定说明。 +# 官方入口若给出不同 tag,只改 BASE_IMAGE。 +ARG BASE_IMAGE=registry.iluvatar.com.cn:10443/customer/sz/vllm0.17.0-4.4.0-x86:v4.1 +FROM ${BASE_IMAGE} + +ENV FRAMEWORK=vllm_0_17_0_corex_4_4_0 \ + GPU_TYPE=Iluvatar_bi-150 \ + TASK_TYPE=text-generation \ + MODEL_DIR=/model \ + ARCH_UNKNOWN_POLICY=fail \ + APPLY_TOKENIZER_PATCH=0 + +WORKDIR /opt/engine +COPY scripts/ /opt/engine/scripts/ +COPY patches/ /opt/engine/patches/ +COPY task_info.env.example /opt/engine/task_info.env + +RUN chmod +x /opt/engine/scripts/entrypoint.sh + +ENTRYPOINT ["/opt/engine/scripts/entrypoint.sh"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..7dfad67 --- /dev/null +++ b/Makefile @@ -0,0 +1,9 @@ +.PHONY: test dry-run + +export PYTHONPATH := scripts + +test: + python3 -m unittest discover -s tests -v + +dry-run: + bash scripts/entrypoint.sh --dry-run diff --git a/README.md b/README.md new file mode 100644 index 0000000..cb2c714 --- /dev/null +++ b/README.md @@ -0,0 +1,67 @@ +# 天垓 150 文本生成兼容引擎 + +本仓是 ModelHub XC 上 Iluvatar bi-150(天垓 150)的文本生成引擎:**Gitea 仓库 + CI 构建镜像**。日常引擎贡献不走 ZIP。 + +**不代提交。** 本仓不向 modelhub.org.cn 提交适配任务,也不调用平台审核接口。推送、打 Tag、在模盒选用引擎都由人完成,步骤见 [docs/upload-runbook.md](docs/upload-runbook.md)。 + +积分:待平台核实。成功看审核通过,以及适配任务能否选用该引擎。 + +## 非目标 + +- Track B(竞赛)为 **NO-GO**。没有 `contest/`,没有竞赛构建。 +- 没有 ZIP 打包,没有上传驱动的 Makefile 目标。 +- `FRAMEWORK` 使用 `vllm_0_17_0_corex_4_4_0`,不使用 `vllm_fix_tokenizer`。 +- tokenizer 修补(I5)未开启。现有 bi150 引擎已覆盖 tokenizer;本仓补的是 chat_template(R2)和 architecture 白名单(R4)。 + +## 基线镜像 + +默认: + +`registry.iluvatar.com.cn:10443/customer/sz/vllm0.17.0-4.4.0-x86:v4.1` + +这个 tag 来自公开仓反推,置信度为中高,不是官方另行发布的锁定说明。官方入口若给出不同 tag,只改 [Dockerfile](Dockerfile) 里的 `BASE_IMAGE`。 + +构建覆盖: + +```bash +docker build --build-arg BASE_IMAGE=<官方 tag> -t modelhub-engine-bi150-compat:dev . +``` + +无卡环境只能验证修补逻辑,不能证明吞吐。 + +## 本地检查 + +需要本机 `python3`、`bash`、`make`。不需要 GPU,也不需要平台账号。 + +```bash +make test +make dry-run +``` + +`make dry-run` 只打印启动计划与模板预览,不启动 vLLM,不访问平台。 + +## 改 FRAMEWORK + +默认值写在 [task_info.env.example](task_info.env.example)。镜像内和 CI 使用同一组值: + +- `GPU_TYPE=Iluvatar_bi-150` +- `FRAMEWORK=vllm_0_17_0_corex_4_4_0` +- `TASK_TYPE=text-generation` + +若平台给出新的 FRAMEWORK 名,同时改这三处,并让 `make test` 保持通过: + +- `task_info.env.example` +- `.gitea/workflows/task_info.env` +- `Dockerfile` 的 `ENV` + +不要改回 `vllm_fix_tokenizer`。 + +## 建仓与选用 + +见 [docs/upload-runbook.md](docs/upload-runbook.md)。事项状态见 [docs/issues.md](docs/issues.md)。证据模板见 [docs/evidence.md](docs/evidence.md)。 + +参考公开仓的启动包装和架构改写思路,没有整仓复制: + +- zhengzhongwei/vLLM-Iluvatar_bi-100-opt +- wanglin/enginex-bi100-compat +- 公开 bi150 tokenizer 引擎只覆盖 tokenizer,本仓补 R2/R4 diff --git a/docs/evidence.md b/docs/evidence.md new file mode 100644 index 0000000..3c524df --- /dev/null +++ b/docs/evidence.md @@ -0,0 +1,14 @@ +# bi150-compat 证据 + +时区:**Asia/Shanghai**。本模板由人工填写。仓库不自动采集审核状态。 + +- 镜像 tag:待填写 +- CI 地址:待填写 +- 审核:未开始 +- 审核通过时间(Asia/Shanghai):待填写 +- 首次被适配任务选用的任务 ID:待填写 +- 选用时间(Asia/Shanghai):待填写 +- 平台状态:未推送 +- 积分:待平台核实 + +无卡本地测试只说明 R2/R4 修补逻辑能跑,不说明天垓 150 上的吞吐。 diff --git a/docs/issues.md b/docs/issues.md new file mode 100644 index 0000000..9fefbed --- /dev/null +++ b/docs/issues.md @@ -0,0 +1,14 @@ +# bi150-compat 事项 + +Track B 不在本仓实现,状态保持 NO-GO。 + +- [x] I0 仓骨架 + README + 非目标声明 +- [x] I1 Dockerfile + task_info.env.example +- [x] I2 wrapper + dry-run +- [x] I3 R2 chat_template 修补 +- [x] I4 R4 architecture 白名单 +- [ ] I5 tokenizer 修补(未开启;等 R2/R4 确认盖不住目标模型族再做) +- [x] I6 CI build(Tag 触发;无凭据时 build-only;不提交平台任务) +- [x] I7 人工 runbook + evidence 模板 + +本地 `make test` 已覆盖 I3/I4 与 dry-run。镜像是否能在 Runner 上拉到基线 tag,要等第一次 `v*` 流水线。 diff --git a/docs/upload-runbook.md b/docs/upload-runbook.md new file mode 100644 index 0000000..6350c38 --- /dev/null +++ b/docs/upload-runbook.md @@ -0,0 +1,39 @@ +# 建仓、打 Tag、选用引擎(人工) + +本仓没有自动提交命令。CI 只构建镜像;仓库凭据齐全时才推送。CI 不调用 modelhub.org.cn,不代领适配任务。 + +积分:待平台核实。 + +## 1. 在 Gitea 建仓 + +1. 在模盒的 Gitea 上新建仓库,名称建议 `modelhub-engine-bi150-compat`。 +2. 把本目录推上去。不要提交 `.env`、密钥或 Token。 +3. 确认默认分支上有 `Dockerfile`、`.gitea/workflows/build.yml` 和 `.gitea/workflows/task_info.env`。 + +## 2. 打 Tag + +CI 在 `v*` 标签上触发,例如 `v0.1.0`。 + +```bash +git tag v0.1.0 +git push origin v0.1.0 +``` + +标签名会成为镜像标签。 + +## 3. 看 CI + +1. 打开该 Tag 的 Actions / 流水线。 +2. `Load task info` 应打印 `FRAMEWORK=vllm_0_17_0_corex_4_4_0` 和 `GPU_TYPE=Iluvatar_bi-150`。 +3. `Build image` 执行 `docker build`。基线镜像拉取失败时,日志会停在构建步骤;无卡不能代替真机验证。 +4. 若 Runner 没有 `DOCKER_REGISTRY`、`DOCKER_USERNAME`、`DOCKER_PASSWORD`,流水线打印 build-only 并成功结束,不推送。 +5. 三份凭据都由 Runner 环境提供,不写进仓库。有凭据时才会 `docker login` 并推送。 + +## 4. 在模盒选用引擎 + +1. 用浏览器登录模盒。 +2. 等镜像审核通过。审核状态由人在页面上确认。 +3. 在适配任务里选用该引擎。本仓不代为创建或提交任务。 +4. 把审核通过时间、镜像 tag、以及首次被选用的适配任务 ID 写入 `docs/evidence.md`。积分栏保持「待平台核实」。 + +字段以登录后的控制台为准。 diff --git a/docs/weekly-report.md b/docs/weekly-report.md new file mode 100644 index 0000000..5c3e24e --- /dev/null +++ b/docs/weekly-report.md @@ -0,0 +1,7 @@ +【引擎 bi150-compat】2026-10-13 +- 本周:I0–I4、I6、I7 完成;I5 未开启。阻塞:尚未推送 Gitea,也还没有真机流水线。 +- 镜像 tag:未构建。默认基线 registry.iluvatar.com.cn:10443/customer/sz/vllm0.17.0-4.4.0-x86:v4.1(公开仓反推) +- 本地 test:见 make test +- 平台:未推送 +- 积分:待核实 +- 下周:人工建仓、打 v* Tag、看 CI;审核通过或被选用后再填 docs/evidence.md diff --git a/patches/architectures/aliases.json b/patches/architectures/aliases.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/patches/architectures/aliases.json @@ -0,0 +1 @@ +{} diff --git a/patches/architectures/whitelist.txt b/patches/architectures/whitelist.txt new file mode 100644 index 0000000..ca8b7ed --- /dev/null +++ b/patches/architectures/whitelist.txt @@ -0,0 +1,13 @@ +# 本仓本地兼容白名单,不是平台公布的完整列表。 +# 只收录 vLLM 0.17 文本生成里常见、且本引擎准备接受的架构名。 +LlamaForCausalLM +Qwen2ForCausalLM +Qwen3ForCausalLM +Qwen3MoeForCausalLM +MistralForCausalLM +MixtralForCausalLM +Gemma2ForCausalLM +Phi3ForCausalLM +InternLM2ForCausalLM +BaichuanForCausalLM +ChatGLMForConditionalGeneration diff --git a/patches/chat_template/default_chat_template.jinja b/patches/chat_template/default_chat_template.jinja new file mode 100644 index 0000000..ceb71f0 --- /dev/null +++ b/patches/chat_template/default_chat_template.jinja @@ -0,0 +1 @@ +{% for message in messages %}{{ '<|' + message['role'] + '|>\n' + message['content'] + '<|end|>\n' }}{% endfor %}{% if add_generation_prompt %}{{ '<|assistant|>\n' }}{% endif %} diff --git a/patches/tokenizer/README.md b/patches/tokenizer/README.md new file mode 100644 index 0000000..4d64176 --- /dev/null +++ b/patches/tokenizer/README.md @@ -0,0 +1,5 @@ +# tokenizer 修补(I5,未开启) + +现有 bi150 引擎已经覆盖 tokenizer。本仓先补 chat_template(R2)和 architecture 白名单(R4)。 + +`APPLY_TOKENIZER_PATCH` 默认为 `0`。在确认某类模型只靠 R2/R4 仍无法加载之前,不要打开这项。 diff --git a/scripts/apply_patches.py b/scripts/apply_patches.py new file mode 100755 index 0000000..3648c13 --- /dev/null +++ b/scripts/apply_patches.py @@ -0,0 +1,187 @@ +#!/usr/bin/env python3 +"""把 R2 chat_template 与 R4 architecture 白名单应用到模型目录。 + +不访问平台,不启动 vLLM。 +""" + +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path + +FORBIDDEN_FRAMEWORK = "vllm_fix_tokenizer" +TOKENIZER_PATCH_KEY = "modelhub_tokenizer_patch" + + +class PatchError(Exception): + """模型目录不满足本引擎的修补前提。""" + + +def engine_root() -> Path: + return Path(__file__).resolve().parents[1] + + +def assert_framework(framework: str) -> None: + if not framework or framework == FORBIDDEN_FRAMEWORK: + raise PatchError( + "FRAMEWORK 必须是 vllm_0_17_0_corex_4_4_0," + "不能使用 vllm_fix_tokenizer" + ) + + +def load_whitelist(path: Path) -> set[str]: + names: set[str] = set() + for line in path.read_text(encoding="utf-8").splitlines(): + item = line.split("#", 1)[0].strip() + if item: + names.add(item) + if not names: + raise PatchError(f"架构白名单为空: {path}") + return names + + +def load_aliases(path: Path) -> dict[str, str]: + data = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(data, dict): + raise PatchError(f"别名文件必须是对象: {path}") + return {str(key): str(value) for key, value in data.items()} + + +def preview_default_template(messages: list[dict[str, str]], add_generation_prompt: bool = True) -> str: + """与 default_chat_template.jinja 同一套角色标记,供无 Jinja 依赖的本地预览。""" + parts: list[str] = [] + for message in messages: + role = message["role"] + content = message["content"] + parts.append(f"<|{role}|>\n{content}<|end|>\n") + if add_generation_prompt: + parts.append("<|assistant|>\n") + return "".join(parts) + + +def apply_chat_template(model_dir: Path, template_text: str, force: bool = False) -> dict: + path = model_dir / "tokenizer_config.json" + if not path.is_file(): + raise PatchError(f"缺少 tokenizer_config.json: {path}") + data = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(data, dict): + raise PatchError("tokenizer_config.json 必须是对象") + current = data.get("chat_template") + if isinstance(current, str) and current.strip() and not force: + return {"injected": False, "reason": "已有 chat_template"} + data["chat_template"] = template_text + path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + return {"injected": True, "reason": "已写入 chat_template"} + + +def apply_architectures( + model_dir: Path, + whitelist: set[str], + aliases: dict[str, str], + unknown_policy: str = "fail", +) -> dict: + if unknown_policy not in {"fail", "skip"}: + raise PatchError("ARCH_UNKNOWN_POLICY 只能是 fail 或 skip") + path = model_dir / "config.json" + if not path.is_file(): + raise PatchError(f"缺少 config.json: {path}") + data = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(data, dict): + raise PatchError("config.json 必须是对象") + architectures = data.get("architectures") + if not isinstance(architectures, list) or not architectures: + raise PatchError("config.json 缺少 architectures") + + rewritten: list[str] = [] + skipped: list[str] = [] + updated: list[str] = [] + for name in architectures: + if not isinstance(name, str) or not name: + raise PatchError("architectures 含有空名称") + if name in whitelist: + updated.append(name) + continue + target = aliases.get(name) + if target in whitelist: + updated.append(target) + rewritten.append(f"{name}->{target}") + continue + if unknown_policy == "skip": + skipped.append(name) + updated.append(name) + continue + raise PatchError(f"未知架构: {name}") + + changed = updated != architectures + if changed: + data["architectures"] = updated + path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + return { + "architectures": updated, + "rewritten": rewritten, + "skipped": skipped, + "changed": changed, + } + + +def apply_tokenizer_patch(model_dir: Path, enabled: bool) -> dict: + if not enabled: + return {"applied": False} + path = model_dir / "tokenizer_config.json" + if not path.is_file(): + raise PatchError(f"缺少 tokenizer_config.json: {path}") + data = json.loads(path.read_text(encoding="utf-8")) + data[TOKENIZER_PATCH_KEY] = "optional-v1" + path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + return {"applied": True} + + +def apply_all( + model_dir: Path, + template_text: str, + whitelist: set[str], + aliases: dict[str, str], + unknown_policy: str = "fail", + force_chat_template: bool = False, + tokenizer_patch: bool = False, +) -> dict: + chat = apply_chat_template(model_dir, template_text, force=force_chat_template) + architectures = apply_architectures(model_dir, whitelist, aliases, unknown_policy) + tokenizer = apply_tokenizer_patch(model_dir, tokenizer_patch) + return {"chat_template": chat, "architectures": architectures, "tokenizer": tokenizer} + + +def main(argv: list[str] | None = None) -> int: + root = engine_root() + parser = argparse.ArgumentParser(description="应用 R2/R4 模型目录修补") + parser.add_argument("--model-dir", required=True, type=Path) + parser.add_argument("--framework", default="vllm_0_17_0_corex_4_4_0") + parser.add_argument("--unknown-policy", default="fail", choices=("fail", "skip")) + parser.add_argument("--force-chat-template", action="store_true") + parser.add_argument("--tokenizer-patch", action="store_true") + parser.add_argument("--template", type=Path, default=root / "patches/chat_template/default_chat_template.jinja") + parser.add_argument("--whitelist", type=Path, default=root / "patches/architectures/whitelist.txt") + parser.add_argument("--aliases", type=Path, default=root / "patches/architectures/aliases.json") + args = parser.parse_args(argv) + try: + assert_framework(args.framework) + result = apply_all( + args.model_dir, + args.template.read_text(encoding="utf-8"), + load_whitelist(args.whitelist), + load_aliases(args.aliases), + unknown_policy=args.unknown_policy, + force_chat_template=args.force_chat_template, + tokenizer_patch=args.tokenizer_patch, + ) + except (PatchError, OSError, json.JSONDecodeError) as exc: + print(f"修补失败: {exc}", file=sys.stderr) + return 1 + print(json.dumps(result, ensure_ascii=False)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/entrypoint.sh b/scripts/entrypoint.sh new file mode 100755 index 0000000..65ca81f --- /dev/null +++ b/scripts/entrypoint.sh @@ -0,0 +1,125 @@ +#!/usr/bin/env bash +# 读取 task_info.env,修补模型目录,并准备 vLLM 启动命令。 +# --dry-run 只打印计划,不启动进程,不访问平台。 +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +usage() { + echo "用法: entrypoint.sh [--dry-run]" >&2 +} + +DRY_RUN=0 +if [[ "${1:-}" == "--dry-run" ]]; then + DRY_RUN=1 +elif [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then + usage + exit 0 +elif [[ -n "${1:-}" ]]; then + usage + exit 1 +fi + +info_file="${TASK_INFO_FILE:-}" +if [[ -z "$info_file" ]]; then + if [[ -f "${ROOT}/task_info.env" ]]; then + info_file="${ROOT}/task_info.env" + else + info_file="${ROOT}/task_info.env.example" + fi +fi + +if [[ ! -f "$info_file" ]]; then + echo "找不到任务信息文件: ${info_file}" >&2 + exit 1 +fi + +# 已在环境中的变量优先于文件,方便本地覆盖。 +while IFS= read -r line || [[ -n "$line" ]]; do + case "$line" in + ""|\#*) continue ;; + esac + key="${line%%=*}" + value="${line#*=}" + if [[ -z "$key" || "$key" == "$line" ]]; then + echo "任务信息行无法解析: ${line}" >&2 + exit 1 + fi + if [[ -z "${!key+x}" ]]; then + export "${key}=${value}" + fi +done <"$info_file" + +FRAMEWORK="${FRAMEWORK:-}" +GPU_TYPE="${GPU_TYPE:-}" +TASK_TYPE="${TASK_TYPE:-text-generation}" +MODEL_DIR="${MODEL_DIR:-/model}" +SERVED_MODEL_NAME="${SERVED_MODEL_NAME:-llm}" +PORT="${PORT:-8000}" +ARCH_UNKNOWN_POLICY="${ARCH_UNKNOWN_POLICY:-fail}" +APPLY_TOKENIZER_PATCH="${APPLY_TOKENIZER_PATCH:-0}" +FORCE_CHAT_TEMPLATE="${FORCE_CHAT_TEMPLATE:-0}" + +if [[ -z "$FRAMEWORK" || "$FRAMEWORK" == "vllm_fix_tokenizer" ]]; then + echo "拒绝 FRAMEWORK=${FRAMEWORK:-空}。本仓使用 vllm_0_17_0_corex_4_4_0。" >&2 + exit 1 +fi +if [[ -z "$GPU_TYPE" ]]; then + echo "GPU_TYPE 为空" >&2 + exit 1 +fi + +plan=( + python3 -m vllm.entrypoints.openai.api_server + --model "$MODEL_DIR" + --port "$PORT" + --served-model-name "$SERVED_MODEL_NAME" + --trust-remote-code +) + +echo "FRAMEWORK=${FRAMEWORK}" +echo "GPU_TYPE=${GPU_TYPE}" +echo "TASK_TYPE=${TASK_TYPE}" +echo "MODEL_DIR=${MODEL_DIR}" +echo "ARCH_UNKNOWN_POLICY=${ARCH_UNKNOWN_POLICY}" +echo "plan: ${plan[*]}" + +if [[ "$DRY_RUN" -eq 1 ]]; then + preview="$( + python3 - "$ROOT" <<'PY' +import sys +from pathlib import Path +root = Path(sys.argv[1]) +sys.path.insert(0, str(root / "scripts")) +import apply_patches +messages = [ + {"role": "system", "content": "你是助手"}, + {"role": "user", "content": "你好"}, +] +print(apply_patches.preview_default_template(messages).replace("\n", "\\n")) +PY + )" + echo "template_preview: ${preview}" + echo "dry-run: 不启动进程,不访问平台" + exit 0 +fi + +if [[ ! -d "$MODEL_DIR" ]]; then + echo "模型目录不存在: ${MODEL_DIR}" >&2 + exit 1 +fi + +patch_args=( + --model-dir "$MODEL_DIR" + --framework "$FRAMEWORK" + --unknown-policy "$ARCH_UNKNOWN_POLICY" +) +if [[ "$FORCE_CHAT_TEMPLATE" == "1" ]]; then + patch_args+=(--force-chat-template) +fi +if [[ "$APPLY_TOKENIZER_PATCH" == "1" ]]; then + patch_args+=(--tokenizer-patch) +fi + +python3 "${ROOT}/scripts/apply_patches.py" "${patch_args[@]}" +exec "${plan[@]}" diff --git a/task_info.env.example b/task_info.env.example new file mode 100644 index 0000000..f24f848 --- /dev/null +++ b/task_info.env.example @@ -0,0 +1,12 @@ +# 本地与镜像内的默认任务信息。平台若给出不同 FRAMEWORK,只改这一处,并保持 Dockerfile 的 ENV 与 CI 文件一致。 +GPU_TYPE=Iluvatar_bi-150 +FRAMEWORK=vllm_0_17_0_corex_4_4_0 +TASK_TYPE=text-generation +MODEL_DIR=/model +SERVED_MODEL_NAME=llm +PORT=8000 +# 未知架构:fail 拒绝启动;skip 只记录并继续。默认拒绝。 +ARCH_UNKNOWN_POLICY=fail +# I5 tokenizer 修补默认关闭。 +APPLY_TOKENIZER_PATCH=0 +FORCE_CHAT_TEMPLATE=0 diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/fixtures/fake-model/config.json b/tests/fixtures/fake-model/config.json new file mode 100644 index 0000000..f0429e5 --- /dev/null +++ b/tests/fixtures/fake-model/config.json @@ -0,0 +1,4 @@ +{ + "model_type": "llama", + "architectures": ["LlamaForCausalLM"] +} diff --git a/tests/fixtures/fake-model/tokenizer.json b/tests/fixtures/fake-model/tokenizer.json new file mode 100644 index 0000000..703ffb7 --- /dev/null +++ b/tests/fixtures/fake-model/tokenizer.json @@ -0,0 +1,3 @@ +{ + "version": "1.0" +} diff --git a/tests/fixtures/fake-model/tokenizer_config.json b/tests/fixtures/fake-model/tokenizer_config.json new file mode 100644 index 0000000..35864ae --- /dev/null +++ b/tests/fixtures/fake-model/tokenizer_config.json @@ -0,0 +1,3 @@ +{ + "tokenizer_class": "LlamaTokenizer" +} diff --git a/tests/test_contract.py b/tests/test_contract.py new file mode 100644 index 0000000..98e9c2f --- /dev/null +++ b/tests/test_contract.py @@ -0,0 +1,96 @@ +import os +import subprocess +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] + + +class EngineContractTest(unittest.TestCase): + def test_locked_values_match(self) -> None: + example = (ROOT / "task_info.env.example").read_text(encoding="utf-8") + workflow_env = (ROOT / ".gitea/workflows/task_info.env").read_text(encoding="utf-8") + dockerfile = (ROOT / "Dockerfile").read_text(encoding="utf-8") + self.assertIn("FRAMEWORK=vllm_0_17_0_corex_4_4_0", example) + self.assertIn("GPU_TYPE=Iluvatar_bi-150", example) + self.assertIn("FRAMEWORK=vllm_0_17_0_corex_4_4_0", workflow_env) + self.assertIn("GPU_TYPE=Iluvatar_bi-150", workflow_env) + self.assertIn("FRAMEWORK=vllm_0_17_0_corex_4_4_0", dockerfile) + self.assertIn("GPU_TYPE=Iluvatar_bi-150", dockerfile) + self.assertIn( + "registry.iluvatar.com.cn:10443/customer/sz/vllm0.17.0-4.4.0-x86:v4.1", + dockerfile, + ) + self.assertNotIn("FRAMEWORK=vllm_fix_tokenizer", example) + self.assertNotIn("FRAMEWORK=vllm_fix_tokenizer", workflow_env) + self.assertNotIn("FRAMEWORK=vllm_fix_tokenizer", dockerfile) + + def test_ci_does_not_submit_platform_task(self) -> None: + workflow = (ROOT / ".gitea/workflows/build.yml").read_text(encoding="utf-8") + self.assertNotIn("adminApi", workflow) + self.assertNotIn("modelhub.org.cn", workflow) + self.assertIn("build-only", workflow) + self.assertIn('tags:', workflow) + + def test_no_zip_or_contest_targets(self) -> None: + makefile = (ROOT / "Makefile").read_text(encoding="utf-8") + self.assertNotIn("package", makefile) + self.assertNotIn("submit", makefile) + self.assertFalse((ROOT / "contest").exists()) + self.assertFalse((ROOT / "computility-run.yaml").exists()) + + def test_dry_run_prints_plan(self) -> None: + env = os.environ.copy() + env.pop("FRAMEWORK", None) + env.pop("GPU_TYPE", None) + env.pop("MODEL_DIR", None) + completed = subprocess.run( + ["bash", "scripts/entrypoint.sh", "--dry-run"], + cwd=ROOT, + env=env, + text=True, + capture_output=True, + check=False, + ) + self.assertEqual(completed.returncode, 0, completed.stderr) + self.assertIn("FRAMEWORK=vllm_0_17_0_corex_4_4_0", completed.stdout) + self.assertIn("GPU_TYPE=Iluvatar_bi-150", completed.stdout) + self.assertIn("python3 -m vllm.entrypoints.openai.api_server", completed.stdout) + self.assertIn("dry-run: 不启动进程,不访问平台", completed.stdout) + self.assertNotIn("modelhub.org.cn", completed.stdout) + self.assertIn("<|user|>", completed.stdout) + + def test_dry_run_rejects_forbidden_framework(self) -> None: + env = os.environ.copy() + env["FRAMEWORK"] = "vllm_fix_tokenizer" + completed = subprocess.run( + ["bash", "scripts/entrypoint.sh", "--dry-run"], + cwd=ROOT, + env=env, + text=True, + capture_output=True, + check=False, + ) + self.assertNotEqual(completed.returncode, 0) + self.assertIn("vllm_fix_tokenizer", completed.stderr) + + def test_tokenizer_patch_stays_off(self) -> None: + import shutil + import tempfile + + import apply_patches + + with tempfile.TemporaryDirectory() as tmp: + model = Path(tmp) / "model" + shutil.copytree(ROOT / "tests" / "fixtures" / "fake-model", model) + result = apply_patches.apply_all( + model, + (ROOT / "patches/chat_template/default_chat_template.jinja").read_text(encoding="utf-8"), + apply_patches.load_whitelist(ROOT / "patches/architectures/whitelist.txt"), + {}, + ) + self.assertFalse(result["tokenizer"]["applied"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_r2_chat_template.py b/tests/test_r2_chat_template.py new file mode 100644 index 0000000..479d1b9 --- /dev/null +++ b/tests/test_r2_chat_template.py @@ -0,0 +1,67 @@ +import json +import shutil +import tempfile +import unittest +from pathlib import Path + +import apply_patches + +ROOT = Path(__file__).resolve().parents[1] +FIXTURE = ROOT / "tests" / "fixtures" / "fake-model" +TEMPLATE = (ROOT / "patches" / "chat_template" / "default_chat_template.jinja").read_text(encoding="utf-8") +WHITELIST = apply_patches.load_whitelist(ROOT / "patches" / "architectures" / "whitelist.txt") +ALIASES = apply_patches.load_aliases(ROOT / "patches" / "architectures" / "aliases.json") + + +class ChatTemplateTest(unittest.TestCase): + def setUp(self) -> None: + self.tmp = tempfile.TemporaryDirectory() + self.model = Path(self.tmp.name) / "model" + shutil.copytree(FIXTURE, self.model) + + def tearDown(self) -> None: + self.tmp.cleanup() + + def test_injects_missing_template(self) -> None: + result = apply_patches.apply_chat_template(self.model, TEMPLATE) + self.assertTrue(result["injected"]) + data = json.loads((self.model / "tokenizer_config.json").read_text(encoding="utf-8")) + self.assertEqual(data["chat_template"], TEMPLATE) + + def test_keeps_existing_template(self) -> None: + path = self.model / "tokenizer_config.json" + data = json.loads(path.read_text(encoding="utf-8")) + data["chat_template"] = "already-set" + path.write_text(json.dumps(data), encoding="utf-8") + result = apply_patches.apply_chat_template(self.model, TEMPLATE) + self.assertFalse(result["injected"]) + kept = json.loads(path.read_text(encoding="utf-8")) + self.assertEqual(kept["chat_template"], "already-set") + + def test_force_overwrites_template(self) -> None: + path = self.model / "tokenizer_config.json" + data = json.loads(path.read_text(encoding="utf-8")) + data["chat_template"] = "already-set" + path.write_text(json.dumps(data), encoding="utf-8") + result = apply_patches.apply_chat_template(self.model, TEMPLATE, force=True) + self.assertTrue(result["injected"]) + + def test_missing_tokenizer_config_fails(self) -> None: + (self.model / "tokenizer_config.json").unlink() + with self.assertRaises(apply_patches.PatchError): + apply_patches.apply_chat_template(self.model, TEMPLATE) + + def test_preview_renders_roles(self) -> None: + text = apply_patches.preview_default_template( + [ + {"role": "system", "content": "你是助手"}, + {"role": "user", "content": "你好"}, + ] + ) + self.assertIn("<|user|>\n你好<|end|>\n", text) + self.assertTrue(text.endswith("<|assistant|>\n")) + self.assertIn("message['role']", TEMPLATE) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_r4_architectures.py b/tests/test_r4_architectures.py new file mode 100644 index 0000000..4d71fe9 --- /dev/null +++ b/tests/test_r4_architectures.py @@ -0,0 +1,65 @@ +import json +import shutil +import tempfile +import unittest +from pathlib import Path + +import apply_patches + +ROOT = Path(__file__).resolve().parents[1] +FIXTURE = ROOT / "tests" / "fixtures" / "fake-model" +WHITELIST = apply_patches.load_whitelist(ROOT / "patches" / "architectures" / "whitelist.txt") + + +class ArchitectureTest(unittest.TestCase): + def setUp(self) -> None: + self.tmp = tempfile.TemporaryDirectory() + self.model = Path(self.tmp.name) / "model" + shutil.copytree(FIXTURE, self.model) + + def tearDown(self) -> None: + self.tmp.cleanup() + + def write_architectures(self, names: list[str]) -> None: + path = self.model / "config.json" + data = json.loads(path.read_text(encoding="utf-8")) + data["architectures"] = names + path.write_text(json.dumps(data), encoding="utf-8") + + def test_whitelist_passes(self) -> None: + result = apply_patches.apply_architectures(self.model, WHITELIST, {}, "fail") + self.assertEqual(result["architectures"], ["LlamaForCausalLM"]) + self.assertFalse(result["changed"]) + + def test_unknown_architecture_fails(self) -> None: + self.write_architectures(["ExampleForCausalLM"]) + with self.assertRaises(apply_patches.PatchError) as ctx: + apply_patches.apply_architectures(self.model, WHITELIST, {}, "fail") + self.assertIn("ExampleForCausalLM", str(ctx.exception)) + + def test_unknown_architecture_skip_keeps_name(self) -> None: + self.write_architectures(["ExampleForCausalLM"]) + result = apply_patches.apply_architectures(self.model, WHITELIST, {}, "skip") + self.assertEqual(result["skipped"], ["ExampleForCausalLM"]) + data = json.loads((self.model / "config.json").read_text(encoding="utf-8")) + self.assertEqual(data["architectures"], ["ExampleForCausalLM"]) + + def test_alias_rewrites_to_whitelist(self) -> None: + self.write_architectures(["LlamaForCausalLMAlias"]) + result = apply_patches.apply_architectures( + self.model, + WHITELIST, + {"LlamaForCausalLMAlias": "LlamaForCausalLM"}, + "fail", + ) + self.assertEqual(result["rewritten"], ["LlamaForCausalLMAlias->LlamaForCausalLM"]) + data = json.loads((self.model / "config.json").read_text(encoding="utf-8")) + self.assertEqual(data["architectures"], ["LlamaForCausalLM"]) + + def test_shipped_aliases_are_empty(self) -> None: + aliases = apply_patches.load_aliases(ROOT / "patches" / "architectures" / "aliases.json") + self.assertEqual(aliases, {}) + + +if __name__ == "__main__": + unittest.main()