参与贡献

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 是一个向后兼容的垫片,把 FixedRouterRouteExecutor 的名字重新导出。要改就改 apps/backend/routing/routers.py

  • 后端的包是 servingrouting,根目录在 apps/backend。任何手工运行的东西都需要 PYTHONPATH=apps/backend;pytest 则从 pyproject.tomlpythonpath 里拿到它。

质量门禁

命令

执行什么

make format

ruff format ., then ruff check --fix --unsafe-fixes .

make lint

ruff format --check ., ruff check --no-fix ., pydocstyle

make test

pytest -m "not external and not dbtest" -n auto --dist loadfile

make check

lint + test

make all

format + check

make all 做类型检查。mypydev 依赖组里,你可以手工运行它,但 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.venvnode_modulesapps/frontendopsdocs,因此 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 有 unitintegrationslowperfexternaldbtest,而 --strict-markers 意味着用了未声明的 marker 就是错误。

层级

位置

Marker

make test 里跑吗?

单元

tests/unit/

API 接口面

tests/api/

服务器 / 可观测性

tests/servers/, tests/observability/

需要一个可连通的 Postgres

主要在 tests/integration/

dbtest

会打到真实的外部服务器

tests/external/, tests/e2e/

external

testpaths["tests", "distributions"],因此 overlay 自带的测试会和 tests/ 一起在默认测试集中运行。

tests/e2e/ 并不是一个独立的 marker 层:它里面的文件和 tests/external/ 里的一样带着 pytestmark = pytest.mark.external,所以 make test-e2epytest -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_HOSTTEST_DB_PORTTEST_DB_USERTEST_DB_PASSWORD(默认为 localhost:5432postgres/postgres),有些还会读完整的 TEST_PG_DSN。只把数据库容器起起来就够了:

docker compose -f deploy/docker/docker-compose.yml --env-file .env up -d postgres

make testpytest-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-parsersphinx-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;针对 maindev 的 PR 都会跑 CI。

  • 分支命名为 <user>/<scope>/<feature-name>,例如 jane/routing/weighted-fallback

  • commit 和 PR 的标题写成 conventional commits 的形式——type(scope): summary,例如 fix(routing): keep the fallback route on 429dev 上的历史按每个 PR 压成一个 commit,所以你写的标题就会成为 commit 的 subject——git log --oneline 里能看到要对齐的样子。

  • 一个 PR 只做一个功能或一处修复,在同一处改动里把文档一起更新,并为新行为补上测试。

  • 不要直接向 maindev 提交。

CI 会跑什么

CI 这个 workflow(.github/workflows/ci.yml)会先归类出一个 PR 改动了哪些路径,然后只运行相关的 job:

Job

作用

Backend Quality

ruff format --check, ruff check --no-fix, pydocstyle

Frontend Quality

prettier, eslint, tsc --noEmit, vitest, npm audit

Docs Build

sphinx-build -W --keep-going

test

在带 PostgreSQL 服务的四个分片上跑 pytest,-m "not external"——因此 dbtest 这一层虽然被 make test 排除,在 CI 里却确实会跑

Security Scan

对整棵代码树跑 gitleaks detect,再对导出的生产依赖清单跑 pip-audit

docker-build

构建受影响的镜像,然后启动那个可运行的 example 并对它做冒烟测试

Tutorial E2E

跑快速开始里的 make upmake smokemake demomake demo-smoke 全流程

CI Gate

把上面各个 job 的结果汇总成一个检查项

因为 CI 包含 dbtest,改动存储或认证时,可能本地全绿而 CI 报红。动到 apps/backend/serving/storage/ 或认证那一面时,要对着本地 Postgres 跑一遍 make test-db

如果一个 PR 一次 CI 运行都没有,先看它是不是有合并冲突——有冲突的 PR 不会触发 pull_request workflow。