用黑盒契约测试守护 Manifest Gateway:读懂 contracts/gateway 的公共 API 稳定性保障
【免费下载链接】llm-gatewayConnect Your Agents And Harnesses With Any Provider 🦚项目地址: https://gitcode.com/GitHub_Trending/manifest7/llm-gateway
Manifest 是一个开源 LLM 网关,把 API Key、订阅套餐和本地模型统一收敛到单一 OpenAI 兼容端点(默认端口2099)上,让每个请求都路由到正确的模型。在快速迭代的路由层代码之上,contracts/gateway目录提供了一套黑盒契约测试:它不导入任何后端源码,只通过 HTTP 访问构建好的应用,守护v1/models、v1/chat/completions、v1/responses、v1/messages这四个公共端点长期稳定、不破坏调用方。读完本文,你将掌握这套契约测试的设计思想、本地运行方式、CI 双套件策略,以及"删除契约必须拆成两个 PR"的演进纪律,并能直接套用到自己的网关类项目上。
一、为什么需要 Gateway 契约测试
网关的本质是对外的稳定承诺:任何 Agent、SDK、CLI 和自动化工具都依赖固定的 HTTP 路径、请求/响应结构和错误语义。如果重构路由代码时不小心改了这些行为,所有接入方都会悄悄"坏掉"。
[contracts/gateway/README.md](https://link.gitcode.com/i/eb43eb2c54026a00465fe33f475cd93e)开宗明义地给出这套测试的定位:
These tests protect stable behavior exposed by the Manifest gateway. They run against the built application over HTTP and must not import backend source code or test helpers.
三个关键约束值得细读:
- 保护"稳定行为":契约测试只覆盖网关对外暴露的、durable 的公共行为,而不是内部实现。
- 对"构建好的应用"运行:测试打在真实运行的 HTTP 服务上,走完整链路(鉴权守卫 → 代理控制器 → 异常过滤器),与单元测试的"mock 一切"形成互补。
- 禁止导入后端源码或测试助手:这是黑盒测试的硬性纪律——如果测试 import 了内部模块,就等于把测试和被测试代码"焊死"在一起,任何重构都会连带炸掉测试,契约也就失去了独立验证的意义。
从仓库结构看,这套理念也写进了协作规范:CLAUDE.md 中明确列出contracts/gateway/是 "gateway API contract tests"。
二、测试套件解剖:routes.test.mjs 逐段讲解
套件的唯一实现文件是 contracts/gateway/tests/routes.test.mjs,全部基于 Node.js 内置的node:test运行器与node:assert/strict,零第三方依赖——这让契约套件可以在任何构建环境中直接执行,无需额外安装测试框架。
1. 环境变量与启动保护
const baseUrl = process.env.MANIFEST_BASE_URL; if (!baseUrl) { throw new Error('MANIFEST_BASE_URL is required'); }契约测试完全由MANIFEST_BASE_URL驱动,指向一个已经构建并启动的 Manifest 实例。缺少该变量时直接抛错,避免在错误配置下产生虚假的通过/失败。
2. request 辅助函数:统一的 HTTP 封装
async function request(path, { method = 'GET', body } = {}) { const response = await fetch(new URL(path, baseUrl), { method, ...(body === undefined ? {} : { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }), signal: AbortSignal.timeout(10_000), }); const text = await response.text(); let responseBody; try { responseBody = JSON.parse(text); } catch { assert.fail(`${path} returned non-JSON response: ${text}`); } return { status: response.status, body: responseBody, text }; }几个值得注意的实现细节:
- 10 秒超时:
AbortSignal.timeout(10_000)保证请求不会无限挂起,CI 中任何一个端点无响应都会快速失败; - 强制 JSON:如果端点返回非 JSON 内容(例如被反向代理的 HTML 错误页吞掉),直接断言失败并输出原文,方便定位;
- 统一返回结构:
{ status, body, text }让后续断言既能检查状态码,也能检查结构化错误体,还能在断言失败时输出完整响应文本辅助调试。
3. 认证断言:契约的核心错误语义
function assertAuthenticationRequired(path, result) { assert.equal(result.status, 401, `${path} returned HTTP ${result.status} instead of 401`); assert.equal(result.body?.error?.type, 'auth_error', `${path} did not return the gateway authentication error`); assert.equal(typeof result.body?.error?.message, 'string', `${path} did not return an authentication error message`); assert.ok(result.body.error.message.trim().length > 0, `${path} returned an empty authentication error message`); }这是整份契约中最有价值的部分——它把"未认证请求"的响应语义固化成四重断言:
- HTTP 状态码必须是 401(而不是 400、403 或 200);
- 错误类型必须是
auth_error(OpenAI SDK 生态里,客户端可以根据error.type判断失败原因); - 错误消息必须是字符串;
- 错误消息不能为空。
也就是说,契约锁定的不只是"会拒绝",而是拒绝的方式。即使将来把 401 改成 403、把auth_error改成invalid_request_error,或者返回空消息,契约测试都会红灯——这正是对下游调用方最有意义的保护。
4. 四个受保护的公共端点
套件用 4 个测试分别探测四个端点是否"保持可用"(remains available),全部以未认证身份发起,期望 401 +auth_error:
| 测试 | 请求 | 请求体要点 |
|---|---|---|
GET /v1/models remains available | GET /v1/models | — |
POST /v1/chat/completions remains available | POST /v1/chat/completions | { model: 'auto', messages: [{ role: 'user', content: 'hello' }] } |
POST /v1/responses remains available | POST /v1/responses | { model: 'auto', input: 'hello' }(OpenAI Responses 风格) |
POST /v1/messages remains available | POST /v1/messages | { model: 'auto', max_tokens: 16, messages: [{ role: 'user', content: 'hello' }] }(Anthropic Messages 风格) |
请求体特意选用model: 'auto'——这是 Manifest 的招牌能力:客户端不指定具体模型,由网关根据路由策略自动选择。契约测试用最简请求体验证端点"活着",把参数校验、provider 细节留给了后端测试。
三、源码印证:这四个端点背后的实现真相
契约测试断言的 401 +auth_error语义并非凭空而来,而是 Manifest 路由层真实实现的结果。核心实现在 packages/backend/src/routing/proxy/proxy.controller.ts。
1. 控制器:一个 v1 前缀下的四个路由
@Controller('v1') @Public() @UseGuards(AgentKeyAuthGuard) @UseFilters(ProxyExceptionFilter) @SkipThrottle() export class ProxyController {@Controller('v1'):所有路由挂载在/v1前缀下;@UseGuards(AgentKeyAuthGuard):每个请求先过 Agent Key 鉴权守卫,这是 401 的来源;@UseFilters(ProxyExceptionFilter):异常统一由代理异常过滤器处理,这是auth_error结构化的来源;@SkipThrottle():代理流量自身不做限流,由上层按需控制。
四个端点的方法签名(见 proxy.controller.ts#L150-L238):
@Get('models') // GET /v1/models @Post('chat/completions') // POST /v1/chat/completions @Post('responses') // POST /v1/responses @Post('messages') // POST /v1/messages三个 POST 端点最终都汇聚到同一个handleProxyRequest(req, res, apiMode),以apiMode区分chat_completions/responses/messages三种代理模式——这也解释了为什么契约测试对三个 POST 端点使用不同风格的请求体:它们对应不同的上游协议形态。
GET /v1/models则更特殊:它会先注入一个id: 'auto'的合成模型条目,再按capabilities=true、cost=true、route_metadata=true三个查询参数决定是否附加能力、价格与路由元数据(见 proxy.controller.ts#L150-L214)。契约测试不认证即可调用它,是因为鉴权守卫在模型列表之前就已拦截。
2. 异常过滤器:401 + auth_error 是怎么来的
packages/backend/src/routing/proxy/proxy-exception.filter.ts 定义了鉴权失败的错误码映射:
const AUTH_ERROR_CODES: Record<string, ManifestErrorCode> = { 'Authorization header required': 'M001', 'Empty token': 'M002', 'Invalid API key format': 'M003', 'API key expired': 'M004', 'Invalid API key': 'M005', };当守卫抛出这些消息时,过滤器对非聊天客户端(curl、SDK、监控、CI 等)返回真实状态码:
const realStatus = status === 400 ? 400 : 401; res.status(realStatus).json({ error: { message: content, type: 'auth_error', code: 'manifest_auth' }, });这正是契约测试断言的401+error.type === 'auth_error'的出处。值得留意的是,过滤器还会用isChatRenderingClient()启发式判断(body.stream === true或Accept: text/event-stream)区分聊天客户端与工具调用方:聊天 UI 收到 HTTP 200 包裹的友好提示,工具/监控拿到真实的 4xx/5xx 状态码(含 429 直通、402 配额拦截等分支)。契约测试用不带 stream 标记的普通请求体,因此走的是"真实状态码"分支。
3. 端到端测试的交叉验证
后端 E2E 测试 packages/backend/test/proxy.e2e-spec.ts 与契约测试互相印证同一语义:
it('rejects unauthenticated requests with HTTP 401', async () => { const res = await api().get('/v1/models').expect(401); // ... expect(res.body.error.type).toBe('auth_error'); });E2E 负责把行为"实现出来"(内含构建与鉴权细节),契约测试负责把行为"固定下来"(黑盒、可独立运行)——两层测试共同守住公共 API 的稳定性。
四、本地运行:三步跑起契约套件
契约测试对本地实例运行,命令如下(来自 contracts/gateway/README.md):
MANIFEST_BASE_URL=http://127.0.0.1:2099 \ node --test contracts/gateway/tests/*.test.mjs执行前需要满足两个前提:
- 本地已启动 Manifest 实例:仓库根 README.md 推荐的 Docker 一条命令安装方式是
bash <(curl -sSL .../docker/install.sh)(详细步骤见 docker/DOCKER_README.md),安装后打开http://localhost:2099注册首个管理员账号。Manifest 的默认端口就是2099——从 deploy/coolify/docker-compose.yml 的PORT=2099到 deploy/easypanel/template/index.ts 的port: 2099,各部署模板均以该端口为默认值; - Node.js 环境支持:
node --test与node:test、fetch、AbortSignal均为内置能力,需要较新的 Node.js 版本(套件本身零 npm 依赖,无需npm install)。
运行后,4 个测试会按契约逐个探测四个端点。把MANIFEST_BASE_URL指向任何运行中的实例(本机、CI 临时容器、甚至远端测试环境),同一套契约即可复用。
五、CI 双套件策略:为什么同一个 PR 要跑两遍
契约测试真正的威力在 Pull Request 检查中。README 明确写道:
Pull request CI runs two copies of the suite against the candidate application:
- The contracts from the pull request's base commit protect existing behavior.
- The contracts from the pull request validate additions and contract changes.
PR CI 会针对候选应用(candidate application,即本 PR 构建出的产物)并行跑两份契约套件:
- 基线契约(base commit):来自 PR 的合并基准提交,代表"改动之前对外承诺的行为"。如果候选应用不再满足它们,说明本 PR破坏了已有契约——立即红灯;
- 候选契约(PR 自身):来自本 PR 的契约文件,代表"改动之后对外承诺的行为"。它验证新增端点、新增参数等契约变更是否真的被实现。
双份对拍的意义在于:一份套件同时充当"回归护栏"和"变更声明"。任何对公共 API 的改动,都必须同时体现在候选契约中,并由基线契约确认没有误伤旧行为。
六、删除契约的两阶段流程:先摘承诺,再删实现
这是契约体系最反直觉、也最严谨的一条规则:
Removing a contract and its implementation therefore requires two pull requests. First remove the contract while the implementation still satisfies the baseline suite. After that merges, remove the implementation.
删除一个"端点 + 它的实现"必须拆成两个 PR:
- 第一个 PR:只删除契约。此时实现仍在,基线契约套件删掉了该端点后依然全绿——对外承诺解除,但 API 仍在服务,给下游留出迁移窗口;
- 第二个 PR:删除实现。等第一个 PR 合并后,再移除后端实现代码。
如果反过来先删实现,基线契约套件会立刻发现"承诺了却没有实现",CI 红灯。这个纪律保证了:契约的废除永远先于实现的移除,任何断崖式破坏公共 API 的改动都无法通过 CI。
七、边界与原则:契约套件为什么必须"小"
README 最后一条准则是整套方法的收尾:
Keep this suite small and limited to durable public behavior. Implementation details and provider-specific edge cases belong in the backend test suite.
- 保持小巧:契约测试是"承诺清单"而非"全量回归",数量过多会让每次契约变更都变成沉重负担;
- 只测 durable 公共行为:路由存在性、鉴权语义这类长期稳定的东西才值得写进契约;
- 实现细节与 provider 边缘情况归后端测试:例如 packages/backend/test/proxy.e2e-spec.ts 里对有效 Agent Key、provider 401 透传、
X-Manifest-*响应头、各上游厂商 URL 映射等大量场景的覆盖,都属于后端套件范畴,不应进入契约。
这套分层让测试各司其职:后端测试追求覆盖密度,契约测试追求承诺稳定。
八、给网关类项目的实践清单
结合 Manifest 的契约套件,可以沉淀出几条可直接复用的经验:
- 用黑盒约束替代白盒信任:契约测试禁止 import 内部模块,迫使你从"调用方视角"定义什么才是稳定的;
- 锁定错误语义,不止锁定状态码:401 之外还断言
error.type与消息非空,杜绝"错误悄悄变形"; - 零依赖运行:
node:test+ 环境变量指目标地址,让同一套契约能在本机、CI、预发任意复用; - 双份套件对拍:基线契约防回归,候选契约声明变更,两者缺一不可;
- 先删契约、后删实现:用两个 PR 完成端点下线,给下游保留迁移窗口;
- 给契约划边界:公共行为进契约,实现细节进后端测试,保持套件精炼。
Manifest 的contracts/gateway展示了一个务实的工程答案:用最少、最黑盒、最严格的测试,守住网关对外的每一句承诺。这套目录虽然只有一份 README 和一个测试文件,却是路由层可以持续重构而不担心破坏接入方的信心来源。
【免费下载链接】llm-gatewayConnect Your Agents And Harnesses With Any Provider 🦚项目地址: https://gitcode.com/GitHub_Trending/manifest7/llm-gateway
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考