LiteLLM Batches API 端到端测试矩阵:四种路由场景、ID 形态断言与跨运行成本回写设计
本文以 LiteLLM 仓库中的 tests/e2e/batches/COVERAGE.md 为核心,拆解 Batches API 实时端到端测试套件(live e2e)的完整覆盖设计:提供商 × 操作矩阵、四种批量请求路由场景与 ID 形态分类、require_managed_files 强制执行的独立测试栈阶段、失败路径契约,以及最精巧的“跨运行标记接力”(cross-run marker baton)终态 + 成本回写测试。读完本文,你既能理解 LiteLLM 如何对 OpenAI / Azure / Vertex AI / Bedrock 四家提供商的批量接口做无跳过的确定性验证,也能掌握在 24 小时完成窗口约束下,如何设计可摊销、可复现的异步批处理测试。
背景:为什么 Batches 的 e2e 只能是“同步层”
LiteLLM 的 Batches 代理接口(litellm/proxy/batches_endpoints/endpoints.py)等价于 POST /v1/batches,允许用户把大批量 LLM 请求异步化处理。由于每个 batch 的 completion_window 为 24 小时,任何“提交后等待完成”的测试都无法在单次 CI 运行内结束。因此该套件被设计为同步层(synchronous tier):
- 生命周期矩阵只断言代理接受、路由、检索、取消、列出一个 batch,从不等待
completed状态; - 测试中创建的一切资源(文件、batch、模型部署)在 teardown 阶段全部删除;
- 唯一的例外是
TestBatchTerminalState,它通过“跨运行标记接力”覆盖completed终态与成本回写(见后文专节)。
测试纪律同样严格:该套件从不跳过(never skip)。缺失提供商凭据或上游失败都会被当作硬性测试失败处理,而不是 skip。
提供商 × 操作矩阵:只测受支持的单元格
COVERAGE.md 的核心是一张 provider × operation 矩阵。由于能力表 capabilities.py 中每个受支持的 (provider, scenario) 组合都对应一行记录,参数化运行时不存在被跳过的单元格。矩阵内容如下:
| 提供商 | 创建 | 检索 | 取消 | 列表 | 内容下载 | 文件后端 |
|---|---|---|---|---|---|---|
| OpenAI | 是 | 是 | 是 | 是 | 是(生命周期 + 终态输出) | OpenAI Files |
| Azure | 是 | 是 | 是 | 是 | 是(字节逐位一致) | Azure Files |
| Vertex AI | 是 | 是 | 是 | 是 | 是(提供商变换后) | GCS(model 上配置 gcs_bucket_name / GCS_BUCKET_NAME) |
| Bedrock | 是(仅 unified 场景) | 是 | 是 | 是(未过滤的托管列表) | 是(提供商变换后) | S3(model 上配置 s3_bucket_name + aws_* + AWS_BATCH_ROLE_ARN) |
矩阵中各提供商的部署参数在 capabilities.py 的 Provider.litellm_params() 中声明:Azure 使用 azure/gpt-5.4-mini-batch 与 AZURE_API_BASE / AZURE_API_KEY;Vertex AI 使用 vertex_ai/gemini-2.5-flash 并要求 gcs_bucket_name;Bedrock 使用 bedrock/us.anthropic.claude-haiku-4-5-20251001-v1:0,同时要求 s3_bucket_name、aws_* 凭据与 aws_batch_role_arn。OpenAI 后端为 gpt-4o-mini,并且在 fixture 模式激活时经由 record/replay edge 路由(LIT-5974),Azure / Vertex / Bedrock 则保持全实时(live)。
Bedrock 的两个特殊约定
- 取消:Bedrock 的 cancel 映射到
StopModelInvocationJob,返回状态为cancelling。测试按与 OpenAI 相同的方式断言它——在 test_batches_e2e.py 中,_CANCEL_ASSERTED_PROVIDERS = frozenset({"openai", "bedrock"}),即只对这两家断言取消语义。若 batch 在取消前 2 秒窗口内恰好完成,则跳过取消断言(对 cancel 单元格是“有文档记录的空通过”,与 OpenAI 相同);list 断言无论如何都会执行。 - 列表:Bedrock 没有提供商侧 list 接口,因此 list 是代理基于数据库(DB)的托管视图:unified 生命周期使用普通的
GET /v1/batches,创建的 batch 必须出现在其中。据 COVERAGE.md 记载,cancel 与 list 两项断言在 LIT-4774 落地 cancel 支持之前、直至 LIT-5730 之前都处于门控关闭状态。 - 文件上传需要 model:Bedrock 的文件上传要求请求携带 model(仅
encoded/unified场景),model_param和provider_fallback场景被有意省略,因为POST /bedrock/v1/files没有无 model 的透传路径。这对应 capabilities.py 中的BEDROCK_SCENARIOS: tuple[Scenario, ...] = ("unified",)。
内容下载:三种后端,三种断言策略
GET /v1/files/{id}/content 针对 unified 上传路径按后端分别验证(test_unified_file_content_downloads):
- Azure 逐字(verbatim)存储 JSONL,因此下载内容被断言与上传内容字节相等;
- Vertex(GCS)与 Bedrock(S3) 在上传时逐行变换内容,因此只断言 200 且返回非空、可解析的 JSON 行;
- Gemini(非 Vertex) 对文件内容抛出
NotImplementedError,不在此矩阵中占格。
四种路由场景:批量请求如何在代理内被路由
每个具备创建能力的提供商都会跑完以下四个场景(SCENARIOS 定义见 capabilities.py)。测试断言返回的 file id 与 batch id 携带该场景应当产生的 ID 形态(matches_id_shape):
| 场景 | 路由方式 | 文件 ID 形态 | 批次 ID 形态 |
|---|---|---|---|
encoded |
带 ?model= 上传 → 模型编码文件 id → 仅用该 id 创建 |
model-encoded | model-encoded |
unified |
带 target_model_names= 上传 → unified 托管文件 id → 用该 id 创建 |
managed | managed |
model_param |
原始文件(provider-fallback 上传)→ 请求体带 model 创建 |
raw | model-encoded |
provider_fallback |
原始文件 → POST /{provider}/v1/batches,环境变量凭据,不带 model |
raw | raw(提供商原生形态) |
三种 ID 形态的判定逻辑就在 capabilities.py 中,是理解整套断言的钥匙:
def is_managed_id(id_str: str) -> bool:
return _b64_decode(id_str).startswith("litellm_proxy")
def is_model_encoded_id(id_str: str) -> bool:
for prefix in ("file-", "batch_"):
if id_str.startswith(prefix):
decoded = _b64_decode(id_str[len(prefix):])
return decoded.startswith("litellm:") and ";model," in decoded
return False
- "managed" id 经 base64 解码后以
litellm_proxy标记开头; - "model-encoded" id 保留提供商前缀(
file-/batch_),其余部分 base64 编码litellm:<id>;model,<model>; - "raw" id 是提供商原生 id。
场景到形态的期望映射由两张表固定(capabilities.py):
FILE_ID_SHAPE: dict[Scenario, IdShape] = {
"encoded": "model_encoded", "unified": "managed",
"model_param": "raw", "provider_fallback": "raw",
}
BATCH_ID_SHAPE: dict[Scenario, IdShape] = {
"encoded": "model_encoded", "unified": "managed",
"model_param": "model_encoded", "provider_fallback": "raw",
}
这套断言能抓住两类代理缺陷:一是在本应托管(manage)的位置返回了 raw id(或相反);二是把请求路由到了错误的提供商——此时创建会直接失败(文件 id / model 不属于该提供商)。对 provider_fallback 场景的 raw batch id,还有额外一层 raw_id_matches_provider 校验(capabilities.py):OpenAI / Azure 的 id 须以 batch 开头、Vertex 须形如 projects/... 或含 batchPredictionJobs、Bedrock 须以 arn:aws:bedrock: 开头。
在 test_batches_e2e.py 的 test_batch_lifecycle 中可以看到断言落地方式:上传后 assert matches_id_shape(FILE_ID_SHAPE[cap.scenario], file.id),创建后 assert matches_id_shape(BATCH_ID_SHAPE[cap.scenario], batch.id),并且 provider_fallback 场景额外走 raw_id_matches_provider 检查。另一个关键细节:provider_fallback 的 id 是 raw 的,后续 retrieve / cancel / list / delete 必须显式携带 provider 提示(走 /{provider}/v1/... 路径),其余三个场景的 id 已内编码了路由信息,可自动路由(见 op_provider,test_batches_e2e.py)。
密钥模型权限限制:403 的两道关卡
test_batch_key_model_access_denied(test_batches_e2e.py)通过 resources.key(models=[...]) 铸造一枚只允许访问单个模型的密钥,然后证明代理在两处都返回 403 key_model_access_denied:
- 该密钥上传一个针对未授权模型的文件(files 端点);
- 该密钥为一个未授权模型创建 batch(batches 端点)。
判定辅助函数 is_model_access_denied 位于 batch_client.py:status_code == 403 and "key_model_access_denied" in resp.body。
逐端点输出断言:校验完整响应体,而不只是 id
COVERAGE.md 明确每个端点都验证完整响应。具体断言在 test_batches_e2e.py 的 assert_file_object 与 assert_batch_object 中:
- 文件上传:
object=="file"、purpose=="batch"、bytes为正数(Bedrock 例外,只要求非空)、有status、有created_at; - batch 创建 / 检索:
object=="batch"、endpoint=="/v1/chat/completions"、completion_window=="24h"、非空input_file_id、created_at > 0;retrieve 还交叉核对返回的id与input_file_id与创建时一致; - 取消:同样的 id、
object=="batch"、状态为cancelling/cancelled; - 列表:
object=="list"信封,且创建的 batch 以 batch 身份出现在data中; - 文件删除:
object=="file"且deleted==True。
这些请求/响应结构体(FileObject、BatchObject、BatchList、FileDeleteResponse 等)全部是 pydantic 模型,与测试同目录共存于 batch_client.py——因为它们只被该套件使用。其中 BatchCreateBody 还固定了默认值:endpoint="/v1/chat/completions"、completion_window="24h"。
套件文件组织与运行时模型注册
| 文件 | 覆盖内容 |
|---|---|
| batch_client.py | 基于共享 ProxyClient 的类型化文件上传/下载 + batch 创建/检索/取消/列表/删除;经 /model/new 的运行时模型注册;拒绝(denial)判定辅助 |
| capabilities.py | provider × scenario 矩阵 + 每提供商 /model/new 参数 + ID 形态分类器 + 每提供商 raw-id 断言 |
| conftest.py | 会话级 batch 部署注册与 teardown |
| test_batches_e2e.py | 参数化生命周期(含逐端点输出断言)、文件上传/删除输出、密钥模型访问拒绝、按后端内容下载、失败路径、第二跳路由、终态 + 成本 |
| test_managed_files_enforcement_e2e.py | require_managed_files 强制执行 pin;除非设置 E2E_MANAGED_FILES_STACK 否则被 deselected |
两个工程化设计值得注意:
- 部署不进代理配置:conftest.py 的
batch_deployments会话级 fixture 在运行开始时经POST /model/new逐一注册openai-batch-<run>、azure-batch-<run>等模型(模型名带unique_marker()生成的每运行后缀,见 capabilities.py 的batch_model_name),teardown 时逐个删除。若代理存活探针不通过,fixture 直接空跑,把失败留给上游门控。 - managed_files 测试的 deselect 钩子:conftest.py 的
pytest_collection_modifyitems在未设置MANAGED_FILES_OPT_IN_ENV(即E2E_MANAGED_FILES_STACK)时把带managed_filesmarker 的用例全部 deselected——与 e2e 套件的weeklymarker 同一模式。
require_managed_files 强制执行:为什么必须独占一个栈阶段
litellm_settings.require_managed_files 是启动期的模块级全局开关,没有 per-key 或运行时覆盖手段,而且一旦开启,所有不带 target_model_names 的上传(包括 files_settings 路由的 provider_fallback 场景)都会被 400。因此这些 pin 不能与套件其余部分共享同一个代理:
test_managed_files_enforcement_e2e.py携带managed_filesmarker,未设置E2E_MANAGED_FILES_STACK时整体 deselected;- PR gate 在主套件跑完后串行追加一个阶段,把同一 ephemeral 栈带着该 flag 重新部署,再单独执行这个文件。
四根 pin(test_managed_files_enforcement_e2e.py):
- 不带
target_model_names的上传 → 400,body 含target_model_names is required; - 带
model参数的上传 → 400,body 含model is not allowed; - retrieve 一个 raw 提供商文件 id → 400,body 含
Raw provider file ids cannot be used; - 跨用户隔离:用户 A 上传获得的 managed unified id,用户 B 检索 → 403
does not have access to this managed file;而属主用户 A 自己仍能正常检索(并断言返回 id 一致)。
失败路径:固化面向客户的错误契约
TestBatchFailurePaths(test_batches_e2e.py)钉住三类客户可见契约:
- 畸形输入文件:上传时即 400,错误信息点名坏内容(
test_malformed_jsonl_upload_rejected)。 - 端点不匹配:JSONL 行中的
url与 batch 的endpoint矛盾时,create 依然通过(提供商是异步校验的),batch 随后被驱动到failed状态,并携带结构化errors.data(code / line / message)、null的output_file_id,以及一条以{batch_id}_batch_cost为键的 $0 花费行——LIT-4852 的契约:失败的 batch 记 $0,而不是让成本跟踪崩溃。之后取消这个 failed batch 应返回 409 并点名终态状态(test_endpoint_mismatch_fails_batch_and_cancel_conflicts)。 - 外部文件 id 优先:一个为某部署编码的文件 id 与请求体中矛盾的
model参数同时出现时,batch 按文件内嵌的 model 路由并重新编码(foreign-id precedence,test_foreign_encoded_file_id_routes_by_file_model)。解析工具decoded_model_from_id可直接从litellm:<id>;model,<model>中解出部署名(capabilities.py)。
第二跳:两个串联的网关
TestBatchSecondHop(test_batches_e2e.py,LIT-5347)注册一个指向代理自身 base URL 的 litellm_proxy/<inner model> 部署,并配一枚新铸造的虚拟密钥,使 unified 上传与创建走 gateway → gateway → OpenAI 的完整链路。pin 的断言:
- 第二跳上
target_model_names被重写为内层部署; - 嵌套的 managed id 能 round-trip 检索成功。
自链只需要代理能触达自己的 PROXY_BASE_URL,本地与 e2e stage 都满足该前提。
终态 + 成本回写:跨运行标记接力(marker baton)
这是整个套件中最有设计感的部分,TestBatchTerminalState 的完整实现见 test_batches_e2e.py。
问题
24 小时完成窗口排除了单次运行内“提交并等待”。但终态覆盖(completed 状态、输出文件下载、成本回写)又是覆盖矩阵中必须有的格子。
方案:把状态留在下一轮
每次运行提交一个 1 行标记 batch(稳定的 metadata 键/值 TERMINAL_MARKER_KEY/TERMINAL_MARKER_VALUE 加每运行唯一字段),并故意从不取消或删除它和它的输入文件——这个 marker 就是下一轮捡起来的“接力棒”(OpenAI 的文件约 30 天后自行过期)。关键设计点:
- 轮询只走 list,最长 5 分钟:因为对非终态 batch 做 retrieve 会记一条 $0 花费行,其
request_id会通过skip_duplicates挡住后来真正的成本行;因此唯一的 retrieve 只在一个 completed marker 出现后才发生(_await_completed_marker,test_batches_e2e.py)。 - 断言目标是任意运行产生的最新 completed marker:由于 run 级部署名(deployment 名带每运行后缀)使 list 以新编码 id 重新编码历史 batch,其花费键是新鲜的,上一轮的 marker 对当前运行而言可计费(billable)。因此在 6 小时间隔的 stage 节奏下,从第 2 轮起完整断言就是确定性的。
- 冷启动是“有文档的空通过”,不是 skip:若轮询预算内没有任何 completed marker,测试仅凭提交断言通过——本轮的 marker 成为下一轮的目标。
- 老化检查:年龄落在 24 小时窗口之外(25h–73h 区间、且位于最新 100 条列表页内)的 marker 必须是终态,否则报“stuck”(
_assert_aged_markers_terminal,test_batches_e2e.py)。
成本断言:LIT-5730 的头条
retrieve 一个 completed 的 model-encoded batch 必须写出一条正花费的行,call_type 为 aretrieve_batch 且带 token 用量。测试最终轮询以 {batch_id}_batch_cost 为 request_id 的花费日志,断言存在 spend > 0 的行、call_type == "aretrieve_batch"、total_tokens > 0。
修复前的缺陷路径(记录于 COVERAGE.md,修复在 litellm/batches/batch_utils.py):retrieve 端点会在排队日志 worker 运行之前就地(in place)重新编码响应中的 output_file_id,worker 拿这个编码后的 id 去请求 OpenAI 得到 404,花费行永远落不了地。当前实现中,call_type=CallTypes.aretrieve_batch.value 的日志构造(batch_utils.py)与 _provider_output_file_id(batch_utils.py)保证了送往提供商的始终是原始/可解析的文件 id。
有意排除的范围
COVERAGE.md 明确划出了两个 out of scope 项:
- Unified(managed)batch 的成本由每小时一次的
CheckBatchCostpoller 负责(相关开关见 litellm/constants.py 附近的PROXY_BATCH_POLLING_ENABLED注释),且对这类 id,终态 DB 状态会让 retrieve 短路。因此终态格子走的是 encoded 路径;poller 的定时行为不适合放进 e2e gate,应归属tests/test_litellm/proxy/下 DI-stub 的代理集成测试。 - Gemini(非 Vertex) 的文件内容在上游抛
NotImplementedError,不构成覆盖单元格。
如何运行与阅读这套测试
- 运行前提:该套件位于
tests/e2e/下,标记为pytest.mark.e2e,需要真实代理实例与真实提供商凭据。从 capabilities.py 的litellm_params()可读出各提供商所需环境变量:OPENAI_API_KEY、AZURE_API_BASE/AZURE_API_KEY、VERTEXAI_PROJECT/VERTEXAI_CREDENTIALS/GCS_BUCKET_NAME、AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY/AWS_REGION/AWS_BATCH_S3_BUCKET(或AWS_S3_BUCKET_NAME)/AWS_BATCH_ROLE_ARN;代理地址来自e2e_config.PROXY_BASE_URL。 - 单独跑 managed_files 阶段:按 COVERAGE.md 所述,PR gate 先跑主套件,再把同一栈以
require_managed_files开启的状态重新部署,设置E2E_MANAGED_FILES_STACK后单独执行test_managed_files_enforcement_e2e.py。 - 覆盖登记:每个用例以
pytest.mark.covers("llm.batches.<provider>.<op>.nonstream.works")声明它覆盖的注册表单元格,单元格集合由coverage_cells_for_lifecycle(capabilities.py)按提供商生成——OpenAI 最细(per-scenario + create / retrieve / cancel / list / file_lifecycle 单元格),Bedrock 在 can_cancel / can_list 门控后追加 cancel 与 list 单元格。
小结
这套 Batches e2e 矩阵展示了如何在“24 小时完成窗口”这一硬约束下构建可复现的异步 API 测试:
- 能力表驱动参数化,让矩阵中不存在跳过格,缺凭据即硬失败;
- ID 形态分类器(managed / model-encoded / raw)把“路由正确性”转化为可断言的字符串结构性质;
- 逐端点完整响应断言,把 OpenAI 契约(object 类型、completion_window、created_at、删除回执)逐字段钉死;
- 独立栈阶段解决“启动期全局开关无法与其余套件共存”的部署冲突;
- 跨运行标记接力把 24 小时尺度的终态与成本回写摊销到多轮运行,并顺带固化了 LIT-4852($0 花费行)与 LIT-5730(成本行丢失)两个缺陷回归。
对维护 LiteLLM 代理的团队,COVERAGE.md 与 test_batches_e2e.py 共同构成了 Batches 接口的“活文档”:任何新提供商接入 batches,只需在 PROVIDERS / capabilities.py 中声明一行能力,即可获得整条生命周期、内容下载与成本回归的自动覆盖。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0627
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00