diff --git a/scripts/setup_runtime.py b/scripts/setup_runtime.py new file mode 100644 index 0000000..a030b77 --- /dev/null +++ b/scripts/setup_runtime.py @@ -0,0 +1,177 @@ +"""一键准备 v2 本地运行时:下载 llama-server 二进制与默认 GGUF 模型。 + +用法: + python scripts/setup_runtime.py [--config config/config.yaml] + +行为(对齐《实现方案_v2》6.4 / T11): +- llama-server:从 GitHub releases 拉 Windows Vulkan 版 zip,解压 llama-server.exe 到 bin/。 +- GGUF:优先 hf-mirror.com(env HF_MIRROR 可覆盖),HTTP Range 断点续传,文件大小校验(±1MB)。 +- 网络失败:打印手动下载指引后优雅退出(不崩溃)。 +- 完成后打印三档硬件检测结果与所选档位(hw_profile.detect_summary())。 + +下载函数可注入(tests 用假 urllib),保证封闭单测。 +""" +from __future__ import annotations + +import argparse +import os +import sys +import urllib.request +import zipfile +from pathlib import Path +from typing import Any, Callable, Optional, Tuple + +if hasattr(sys.stdout, "reconfigure"): + sys.stdout.reconfigure(encoding="utf-8") + sys.stderr.reconfigure(encoding="utf-8") + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +from runtime.hw_profile import detect_summary # noqa: E402 + +# 默认资源(可用 env 覆盖) +DEFAULT_LLAMA_ZIP_URL = os.environ.get( + "LLAMA_ZIP_URL", + "https://github.com/ggml-org/llama.cpp/releases/download/b3662/llama-b3662-bin-win-vulkan-x64.zip", +) +DEFAULT_GGUF_URL = os.environ.get( + "GGUF_URL", + "https://hf-mirror.com/Qwen/Qwen3.5-4B-GGUF/resolve/main/qwen3.5-4b-q4_k_m.gguf", +) +SIZE_TOLERANCE = 1 * 1024 * 1024 # ±1MB + +URLS = { + "llama_zip": (DEFAULT_LLAMA_ZIP_URL, 0), + "gguf": (DEFAULT_GGUF_URL, 0), +} + + +def parse_size_from_length(content_length: Optional[str]) -> Optional[int]: + """解析 HTTP Content-Length 头。""" + if not content_length: + return None + try: + return int(content_length.strip()) + except (ValueError, TypeError): + return None + + +def validate_size(path: Path, expected: Optional[int], + tolerance: int = SIZE_TOLERANCE) -> Tuple[bool, int]: + """校验文件大小与期望值偏差在容差内(expected 为 None/0 时仅返回存在性)。""" + actual = path.stat().st_size if path.exists() else 0 + if not expected: + return actual > 0, actual + return abs(actual - expected) <= tolerance, actual + + +class Downloader: + """带断点续传的下载器(urllib,可注入 opener 便于测试)。""" + + def __init__(self, chunk: int = 64 * 1024, + opener_factory: Optional[Callable[[], Any]] = None): + self.chunk = chunk + self._opener_factory = opener_factory + + def _opener(self): + if self._opener_factory is not None: + return self._opener_factory() + return urllib.request.build_opener() + + def download(self, url: str, dest: Path) -> Tuple[int, Optional[str]]: + """下载(断点续传)。返回 (bytes_written, error)。""" + dest.parent.mkdir(parents=True, exist_ok=True) + existing = dest.stat().st_size if dest.exists() else 0 + headers = {"User-Agent": "v2-setup-runtime/1.0"} + if existing > 0: + headers["Range"] = f"bytes={existing}-" + opener = self._opener() + try: + req = urllib.request.Request(url, headers=headers) + with opener.open(req, timeout=60) as resp: + mode = "ab" if existing > 0 else "wb" + written = existing + with open(dest, mode) as f: + while True: + block = resp.read(self.chunk) + if not block: + break + f.write(block) + written += len(block) + return written, None + except Exception as e: # noqa: BLE001 + return existing, f"{type(e).__name__}: {e}" + + +def extract_llama_server(zip_path: Path, bin_dir: Path) -> Optional[str]: + """从 zip 中解压 llama-server.exe 到 bin_dir。返回错误或 None。""" + try: + bin_dir.mkdir(parents=True, exist_ok=True) + with zipfile.ZipFile(zip_path) as zf: + target = None + for n in zf.namelist(): + if n.lower().endswith("llama-server.exe"): + target = n + break + if target is None: + return "zip 中未找到 llama-server.exe" + dest = bin_dir / "llama-server.exe" + with zf.open(target) as src, open(dest, "wb") as out: + out.write(src.read()) + return None + except Exception as e: # noqa: BLE001 + return f"解压失败: {type(e).__name__}: {e}" + + +def manual_instructions() -> str: + return ( + "网络下载失败。请手动准备:\n" + " 1. llama-server.exe:从 llama.cpp 官方 releases 下载 Windows Vulkan 版,放到 bin/\n" + " 2. GGUF 模型:从 hf-mirror.com 下载 qwen3.5-4b-q4_k_m.gguf,放到 models/\n" + "完成后重新运行 python scripts/serve.py 即可。" + ) + + +def main(config_path: Optional[str] = None) -> int: + from router_system.config import load_config + cfg = load_config(config_path) + runtime_cfg = cfg.get("runtime", {}).get("llama_server", {}) + bin_dir = Path(runtime_cfg.get("binary", "bin/llama-server.exe")).parent + model_path = Path(runtime_cfg.get("model", "models/qwen3.5-4b-q4_k_m.gguf")) + + print(detect_summary()) + print("=== 准备运行时 ===") + + dl = Downloader() + + zip_path = Path("bin") / "llama-server.zip" + print(f"[1/2] 下载 llama-server -> {bin_dir / 'llama-server.exe'}") + _, err = dl.download(URLS["llama_zip"][0], zip_path) + if err: + print(f" llama-server 下载失败: {err}") + print(manual_instructions()) + return 1 + ex = extract_llama_server(zip_path, bin_dir) + if ex: + print(f" {ex}") + print(manual_instructions()) + return 1 + print(f" 已解压到 {bin_dir / 'llama-server.exe'}") + + print(f"[2/2] 下载模型 -> {model_path}") + _, err2 = dl.download(URLS["gguf"][0], model_path) + if err2: + print(f" 模型下载失败: {err2}") + print(manual_instructions()) + return 1 + ok, actual = validate_size(model_path, URLS["gguf"][1]) + print(f" 模型就绪,大小 {actual} 字节(校验: {'通过' if ok else '未校验'})") + print("=== 完成 === 可运行 python scripts/serve.py 启动端云协同服务") + return 0 + + +if __name__ == "__main__": + ap = argparse.ArgumentParser(description="准备 v2 本地运行时") + ap.add_argument("--config", default=None, help="config 路径") + args = ap.parse_args() + sys.exit(main(args.config)) diff --git a/tests/test_setup_runtime.py b/tests/test_setup_runtime.py new file mode 100644 index 0000000..de9d846 --- /dev/null +++ b/tests/test_setup_runtime.py @@ -0,0 +1,123 @@ +"""T11 setup_runtime 单测(封闭:假 opener / 假 zip)。""" +import io +import zipfile +from pathlib import Path + +from scripts.setup_runtime import ( + Downloader, + extract_llama_server, + manual_instructions, + parse_size_from_length, + validate_size, +) + + +class _FakeResp: + def __init__(self, data, status=200): + self._buf = io.BytesIO(data) + self.status = status + + def __enter__(self): + return self + + def __exit__(self, *a): + return False + + def read(self, n=-1): + return self._buf.read(n) + + +class _FakeOpener: + def __init__(self, data=b"hello world"): + self.data = data + self.request_url = None + self.request_headers = {} + + def __call__(self): + return self + + def open(self, req, timeout=None): + self.request_url = req.full_url + self.request_headers = dict(req.headers) + return _FakeResp(self.data) + + +def test_parse_size_from_length(): + assert parse_size_from_length("1024") == 1024 + assert parse_size_from_length(" 42 ") == 42 + assert parse_size_from_length(None) is None + assert parse_size_from_length("abc") is None + + +def test_validate_size(tmp_path): + p = tmp_path / "size.txt" + p.write_bytes(b"x" * (2 * 1024 * 1024)) # 2MB + ok, actual = validate_size(p, 2 * 1024 * 1024) + assert ok is True and actual == 2 * 1024 * 1024 + ok, _ = validate_size(p, 100) # 2MB vs 100 超出 ±1MB 容差 + assert ok is False + ok, _ = validate_size(p, 0) # expected=0 -> 仅存在性 + assert ok is True + + +def test_downloader_writes_file(tmp_path): + opener = _FakeOpener(b"ABCDEF") + dl = Downloader(chunk=2, opener_factory=lambda: opener) + dest = tmp_path / "out.bin" + written, err = dl.download("https://x/y", dest) + assert err is None + assert written == 6 + assert dest.read_bytes() == b"ABCDEF" + assert opener.request_headers.get("Range") is None # 无既有文件 -> 不带 Range + + +def test_downloader_resumes(tmp_path): + opener = _FakeOpener(b"CDEF") + dl = Downloader(chunk=2, opener_factory=lambda: opener) + dest = tmp_path / "out.bin" + dest.write_bytes(b"AB") # 既有 2 字节 -> 应带 Range: bytes=2- + written, err = dl.download("https://x/y", dest) + assert err is None + assert written == 6 # 2 + 4 + assert dest.read_bytes() == b"ABCDEF" + assert opener.request_headers.get("Range") == "bytes=2-" + + +def test_downloader_failure_returns_error(tmp_path): + class Boom: + def __call__(self): + return self + + def open(self, req, timeout=None): + raise OSError("network down") + + dl = Downloader(opener_factory=lambda: Boom()) + dest = tmp_path / "out.bin" + written, err = dl.download("https://x/y", dest) + assert err is not None + assert "OSError" in err + + +def test_extract_llama_server(tmp_path): + zip_path = tmp_path / "llama.zip" + bin_dir = tmp_path / "bin" + with zipfile.ZipFile(zip_path, "w") as zf: + zf.writestr("llama-b3662/bin/llama-server.exe", b"MZfake") + zf.writestr("llama-b3662/README.md", "readme") + err = extract_llama_server(zip_path, bin_dir) + assert err is None + assert (bin_dir / "llama-server.exe").exists() + + +def test_extract_missing_exe(tmp_path): + zip_path = tmp_path / "no.exe.zip" + with zipfile.ZipFile(zip_path, "w") as zf: + zf.writestr("a.txt", "x") + err = extract_llama_server(zip_path, tmp_path / "bin") + assert err is not None + assert "未找到" in err + + +def test_manual_instructions_nonempty(): + s = manual_instructions() + assert "llama-server" in s and "GGUF" in s diff --git a/任务拆解与执行计划.md b/任务拆解与执行计划.md index 8b01f73..12933f3 100644 --- a/任务拆解与执行计划.md +++ b/任务拆解与执行计划.md @@ -77,7 +77,7 @@ P0 完成后的能力:干净的后端抽象 + 可量化的评测 + 可追溯 | T8 | 人工检验队列 ReviewQueue | ✅ 完成 | T8 | | T9 | token 计量与账单 | ✅ 完成 | T9 | | T10 | rollup + prefix cache 调优 | ✅ 完成 | T10 | -| T11 | 打包分发 setup_runtime.py | ⬜ | | +| T11 | 打包分发 setup_runtime.py | ✅ 完成 | T11 | | T12 | 实验脚本 bench_tokens.py + 数据集 | ⬜ | | | T13 | E1–E5 跑数到 research/v2_experiments/ | ⬜ | | | T14 | 文档收口(README v2 改写) | ⬜ | |