参与贡献
HybridInference 采用 MIT 许可(见 LICENSE),欢迎贡献。本页说明仓库里都有什么、一处改动要通过哪些检查,以及一处改动如何提出来。
环境准备
git clone <your-fork-url> hybridinference
cd hybridinference
make setup-dev
make setup-dev 会用 Python 3.12 创建 .venv,以可编辑方式安装本项目,同步 dev 依赖组,并装上 pre-commit 钩子。完整的前置条件和运行网关的各种方式见 安装。
每一个涉及 Python 的 make 目标都通过 uv run 执行。如果你的环境需要,可以覆盖它:
make lint UV_RUN="uv run --active"
仓库结构
apps/
backend/
serving/ FastAPI gateway: HTTP surface, SSE streaming, provider
adapters, auth, storage, observability, admin API
routing/ routing engine: routers, strategies, endpoint health,
circuit breaker
frontend/ Next.js web and admin console
config/
examples/ reference model registry and routing config; also the
built-in fallback a checkout with no overlay resolves to
distributions/ deployment overlays, one directory each: manifest, config,
branding, deploy env files. `example/` is the runnable
teaching overlay used by the Router Tutorial
deploy/
docker/ Dockerfiles and docker-compose.yml
systemd/ unit files for host-level deployments
docs/
developer/ this guide (MyST Markdown, built with Sphinx)
agents/ design specs and plans
ops/ maintenance and CI helper scripts (admin, ci, db)
services/ standalone protocol-conformance harnesses and testkits
tests/ see the test tiers below
benchmark/ benchmarking scripts
后端有两件事很容易搞错:
apps/backend/routing/executor.py是一个向后兼容的垫片,把FixedRouter以RouteExecutor的名字重新导出。要改就改apps/backend/routing/routers.py。后端的包是
serving和routing,根目录在apps/backend。任何手工运行的东西都需要PYTHONPATH=apps/backend;pytest 则从pyproject.toml的pythonpath里拿到它。
质量门禁
命令 |
执行什么 |
|---|---|
|
|
|
|
|
|
|
|
|
|
make all 不做类型检查。mypy 在 dev 依赖组里,你可以手工运行它,但 pyproject.toml 里没有 [tool.mypy] 段,也没有任何 target 会调用它,所以没有任何东西强制它。
代码风格由工具强制,而不是靠评审:
ruff,行长 100,target 为
py310,启用E/W/F/I/UP/B/C4/SIM/TCH/RUF(见pyproject.toml的[tool.ruff])。pydocstyle,Google 约定。它跳过
tests、.venv、node_modules、apps/frontend、ops和docs,因此apps/backend里必须写 docstring,并且在那里被强制执行。
pre-commit 钩子在 commit 时运行,由 make setup-dev 安装:
pre-commit run --all-files # run them over the whole tree
它们涵盖 ruff(版本锁定为 CI 所用的那个)、pydocstyle、gitleaks 密钥扫描、标准的空白字符/YAML/JSON/TOML 检查,以及针对 apps/frontend 的 eslint + prettier。
开 PR 之前,先跑 make format,并确认 make test 通过。
测试
marker 才是约定,目录只是习惯。pyproject.toml 里声明的 marker 有 unit、integration、slow、perf、external 和 dbtest,而 --strict-markers 意味着用了未声明的 marker 就是错误。
层级 |
位置 |
Marker |
在 |
|---|---|---|---|
单元 |
|
— |
是 |
API 接口面 |
|
— |
是 |
服务器 / 可观测性 |
|
— |
是 |
需要一个可连通的 Postgres |
主要在 |
|
否 |
会打到真实的外部服务器 |
|
|
否 |
testpaths 是 ["tests", "distributions"],因此 overlay 自带的测试会和 tests/ 一起在默认测试集中运行。
tests/e2e/ 并不是一个独立的 marker 层:它里面的文件和 tests/external/ 里的一样带着 pytestmark = pytest.mark.external,所以 make test-e2e(pytest -m external)两边都会选中。它的区别在于自带一份 tests/e2e/Makefile,会在跑测试之前把这些阶段测试所需要的整套服务先立起来。
make test # the default suite
make test-verbose # same selection, serial, -vv
make test-cov # same selection, with coverage
make test-db # only -m dbtest
make test-all # everything except -m external
make test-e2e # only -m external
uv run pytest tests/unit/routing/test_manager.py # one file
uv run pytest -m dbtest tests/integration/ # one tier
dbtest 这一层需要一个可连通的 PostgreSQL。测试会读 TEST_DB_HOST、TEST_DB_PORT、TEST_DB_USER、TEST_DB_PASSWORD(默认为 localhost:5432 和 postgres/postgres),有些还会读完整的 TEST_PG_DSN。只把数据库容器起起来就够了:
docker compose -f deploy/docker/docker-compose.yml --env-file .env up -d postgres
make test 用 pytest-xdist 以文件为粒度并行。调查某个具体失败时可以串行跑,但怀疑是环境变量泄漏引起的失败,还要在 -n auto 下再查一遍——跨文件的环境泄漏只有在并行运行时才会暴露。
前端
控制台的门禁与 Python 那套是分开的:
make frontend-install # npm ci
make frontend-lint # eslint, --max-warnings 0
make frontend-type-check # tsc --noEmit
make frontend-test # vitest run
make frontend-check # all three
make check-all # backend lint + test, then frontend-check
CI 还会在 apps/frontend 里额外运行 npm run format:check(prettier)和 npm audit --omit=dev --audit-level=high。
文档
这些页面是 MyST Markdown,由 Sphinx 从 docs/developer/ 编译,以 docs/developer/index.rst 作为根目录树。在仓库根目录构建:
make docs
CI 的 Docs Build 任务构建同一棵树,并把警告升级为错误:
uv run sphinx-build -b html docs/developer docs/build/html -W --keep-going
本仓库里的文档改动就是由这个任务把关的——交叉引用断了、或者某个页面没进目录树,构建都会失败,所以推送之前先在本地跑一遍。Sphinx、myst-parser 和 sphinx-rtd-theme 都来自 dev 依赖组,因此 make setup-dev 之后就能构建。
写一个新页面的同时,要把它加进 docs/developer/index.rst;孤立文件会产生警告,而在 CI 里警告就是错误。
翻译
这些页面用英文书写,并通过 Sphinx 的 gettext 流程翻译,因此译文挂在源文本的每一个段落上,而不是挂在整份文件上。这正是局部翻译之所以安全的原因:任何没有译文的字符串都会回退到英文,站点照样能完整构建出来。
make docs-gettext # extract one catalog template per page
make docs-translate DOCS_LANG=zh_CN # create or update that language's catalogs
# edit docs/developer/locale/zh_CN/LC_MESSAGES/*.po -- fill in msgstr
make docs-lang DOCS_LANG=zh_CN # build it and read the result
消息目录里的一条记录,把英文原文和它的译文配成一对:
#: ../index.rst:4
msgid "HybridInference is an open-source LLM inference gateway."
msgstr "HybridInference 是一个开源的 LLM 推理网关。"
由于英文文本就是查找键,编辑一个段落会自动让它的译文失效:下一次 make docs-translate 会把那条记录标成 #, fuzzy,构建就不再使用它,页面回退到英文,而不是继续提供一份已经和代码对不上的译文。译者只需要回头处理被标记的那些条目。这就是采用消息目录、而不是并列一份 .zh.md 文件的理由——后者会悄悄漂移,读者完全看不出自己读到的内容已经过时。
要提交 .po 文件。docs/gettext/ 是生成物,已被忽略。
翻译一个页面不要求把它全部翻完,也没有义务让某种语言保持完整——未翻译的段落是一种回退,不是缺陷。
翻译成中日韩语言时
四个坑,全都是静默的——构建照样全绿,页面却是错的。这份文档翻成中文时,四个都踩到了。
以数字开头的标题会被丢弃。Sphinx 会重新解析翻译后的标题,而 MyST 把 1. 读成有序列表标记而不是文本。结构与原文对不上,翻译被丢弃,输出的是英文标题,而且没有任何警告——-W 下也没有。把那个点转义掉:
msgstr "1\. 申请一个节点"
在 index.rst 里,紧贴 CJK 字符的行内标记不会被解析。reStructuredText 要求开头的 * 前面是空白或特定标点,而汉字两者都不是,于是 请求的*模型 id* 渲染成字面星号。用一个转义空格把它们隔开——在 catalog 里写作 \\ ,也就是字符串里的一个反斜杠加空格:
msgstr "客户端请求的\\ *模型 id*\\ ,与真正服务它的\\ *端点*\\ 是解耦的。"
这条只适用于 index.rst。Markdown 页面不需要转义:CommonMark 认为 CJK 字符既不是空白也不是标点,所以夹在汉字之间的 **模型 id** 是合法的强调。
但 Markdown 有一种情况确实会坏:结尾的 ** 前面是句号、后面是汉字时不构成右侧闭合,于是 **术语。**后文 会留下字面星号。把句号移到加粗外面——**术语**。后文——这本来也是更好的排版,给标点加粗是错的。
过期的 .mo 会掩盖你的改动。当 .mo 比它的 .po 新时,Sphinx 会跳过重新编译,于是你验证的那次构建根本没读到你的修改。每次验证构建之前先执行:
find docs/developer/locale -name '*.mo' -delete
能一次抓住这四种的检查,是与英文构建做结构 diff:逐页比较 <code>、<strong>、<em>、<a> 的数量,以及行内代码字面量和链接目标的多重集合。丢掉一个标记、或者把链接目标也翻译了,只有这个检查能发现。
还有一个,好在它至少会明着报错:make docs-translate 可能会在你已标注 no-python-format 的条目上再追加一个 python-format——源串里出现类似 ≥ 5% 的写法,在 gettext 看来就像格式串——随后 msgfmt -c 会拒绝这对互相矛盾的标记。把新加的 python-format 删掉,保留 no-python-format 即可。
翻译是怎么上到已发布站点的
make docs 用一次 sphinx-build 构建出所有已发布的语言。DOCS_LANGUAGES(在 conf.py 里声明,默认是 en:English,zh_CN:简体中文)里排第一的语言是根语言,落在输出目录的顶层;其余每种语言由 conf.py 里一个 build-finished 钩子写进 docs/build/html/<code>/。
之所以是一次调用而不是每种语言一次:已发布站点的构建命令在托管方的设置里,不在本仓库中——发布时没有环境变量可以设,所以语言列表必须写在 conf.py 里随代码走。而把根语言留在树顶,是为了保住纯英文站曾有的每一个 URL:/routing.html 还是 /routing.html,翻译版在 /zh_CN/routing.html。
侧边栏的切换器指向其他每种语言的同一页;声明的语言少于两种时它什么都不渲染,所以单语言站点不会出现一个点了没用的控件。只想构建英文时用 DOCS_LANGUAGES="en:English"。
抓「翻译悄悄退回英文」的那道检查
make docs-verify
就是 make docs 加上 ops/ci/check_docs_translations.py,也正是 Docs Build 这个 CI job 跑的东西。它之所以存在,是因为上面那些失效方式 -W 一个都看不见:任何 Sphinx 翻不了的字符串都会回落到英文原文,所以一个已经悄悄退回英文的页面照样构建得干干净净。四项检查——
fuzzy 条目,Sphinx 拒绝使用它们;
缺失的目录,即新加了页面却没跑
make docs-translate;过期的目录——改过的英文句子其
msgid已经匹配不上任何条目。这是最常见的情况,而且根本不会带fuzzy标记,因为压根没人重新合并过;构建产物之间的结构差异,它抓的正是上面那些紧挨 CJK 的标记陷阱。
提出改动
从
dev开分支,而不是main。PR 提向dev;针对main和dev的 PR 都会跑 CI。分支命名为
<user>/<scope>/<feature-name>,例如jane/routing/weighted-fallback。commit 和 PR 的标题写成 conventional commits 的形式——
type(scope): summary,例如fix(routing): keep the fallback route on 429。dev上的历史按每个 PR 压成一个 commit,所以你写的标题就会成为 commit 的 subject——git log --oneline里能看到要对齐的样子。一个 PR 只做一个功能或一处修复,在同一处改动里把文档一起更新,并为新行为补上测试。
不要直接向
main或dev提交。
CI 会跑什么
CI 这个 workflow(.github/workflows/ci.yml)会先归类出一个 PR 改动了哪些路径,然后只运行相关的 job:
Job |
作用 |
|---|---|
Backend Quality |
|
Frontend Quality |
prettier, eslint, |
Docs Build |
|
|
在带 PostgreSQL 服务的四个分片上跑 pytest, |
Security Scan |
对整棵代码树跑 |
|
构建受影响的镜像,然后启动那个可运行的 example 并对它做冒烟测试 |
Tutorial E2E |
跑快速开始里的 |
CI Gate |
把上面各个 job 的结果汇总成一个检查项 |
因为 CI 包含 dbtest,改动存储或认证时,可能本地全绿而 CI 报红。动到 apps/backend/serving/storage/ 或认证那一面时,要对着本地 Postgres 跑一遍 make test-db。
如果一个 PR 一次 CI 运行都没有,先看它是不是有合并冲突——有冲突的 PR 不会触发 pull_request workflow。