首页
/ LiteLLM Batches API 端到端测试矩阵:四种路由场景、ID 形态断言与跨运行成本回写设计

LiteLLM Batches API 端到端测试矩阵:四种路由场景、ID 形态断言与跨运行成本回写设计

2026-09-07 16:28:59作者:柯茵沙

本文以 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_window24 小时,任何“提交后等待完成”的测试都无法在单次 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.pyProvider.litellm_params() 中声明:Azure 使用 azure/gpt-5.4-mini-batchAZURE_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_nameaws_* 凭据与 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_paramprovider_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.pytest_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_providertest_batches_e2e.py)。

密钥模型权限限制:403 的两道关卡

test_batch_key_model_access_deniedtest_batches_e2e.py)通过 resources.key(models=[...]) 铸造一枚只允许访问单个模型的密钥,然后证明代理在两处都返回 403 key_model_access_denied

  1. 该密钥上传一个针对未授权模型的文件(files 端点);
  2. 该密钥为一个未授权模型创建 batch(batches 端点)。

判定辅助函数 is_model_access_denied 位于 batch_client.pystatus_code == 403 and "key_model_access_denied" in resp.body

逐端点输出断言:校验完整响应体,而不只是 id

COVERAGE.md 明确每个端点都验证完整响应。具体断言在 test_batches_e2e.pyassert_file_objectassert_batch_object 中:

  • 文件上传object=="file"purpose=="batch"bytes 为正数(Bedrock 例外,只要求非空)、有 status、有 created_at
  • batch 创建 / 检索object=="batch"endpoint=="/v1/chat/completions"completion_window=="24h"、非空 input_file_idcreated_at > 0;retrieve 还交叉核对返回的 idinput_file_id 与创建时一致;
  • 取消:同样的 id、object=="batch"、状态为 cancelling / cancelled
  • 列表object=="list" 信封,且创建的 batch 以 batch 身份出现在 data 中;
  • 文件删除object=="file"deleted==True

这些请求/响应结构体(FileObjectBatchObjectBatchListFileDeleteResponse 等)全部是 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

两个工程化设计值得注意:

  1. 部署不进代理配置conftest.pybatch_deployments 会话级 fixture 在运行开始时经 POST /model/new 逐一注册 openai-batch-<run>azure-batch-<run> 等模型(模型名带 unique_marker() 生成的每运行后缀,见 capabilities.pybatch_model_name),teardown 时逐个删除。若代理存活探针不通过,fixture 直接空跑,把失败留给上游门控。
  2. managed_files 测试的 deselect 钩子conftest.pypytest_collection_modifyitems 在未设置 MANAGED_FILES_OPT_IN_ENV(即 E2E_MANAGED_FILES_STACK)时把带 managed_files marker 的用例全部 deselected——与 e2e 套件的 weekly marker 同一模式。

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_files marker,未设置 E2E_MANAGED_FILES_STACK 时整体 deselected;
  • PR gate 在主套件跑完后串行追加一个阶段,把同一 ephemeral 栈带着该 flag 重新部署,再单独执行这个文件。

四根 pin(test_managed_files_enforcement_e2e.py):

  1. 不带 target_model_names 的上传 → 400,body 含 target_model_names is required
  2. model 参数的上传 → 400,body 含 model is not allowed
  3. retrieve 一个 raw 提供商文件 id → 400,body 含 Raw provider file ids cannot be used
  4. 跨用户隔离:用户 A 上传获得的 managed unified id,用户 B 检索 → 403 does not have access to this managed file;而属主用户 A 自己仍能正常检索(并断言返回 id 一致)。

失败路径:固化面向客户的错误契约

TestBatchFailurePathstest_batches_e2e.py)钉住三类客户可见契约:

  1. 畸形输入文件:上传时即 400,错误信息点名坏内容(test_malformed_jsonl_upload_rejected)。
  2. 端点不匹配:JSONL 行中的 url 与 batch 的 endpoint 矛盾时,create 依然通过(提供商是异步校验的),batch 随后被驱动到 failed 状态,并携带结构化 errors.data(code / line / message)、nulloutput_file_id,以及一条以 {batch_id}_batch_cost 为键的 $0 花费行——LIT-4852 的契约:失败的 batch 记 $0,而不是让成本跟踪崩溃。之后取消这个 failed batch 应返回 409 并点名终态状态(test_endpoint_mismatch_fails_batch_and_cancel_conflicts)。
  3. 外部文件 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)。

第二跳:两个串联的网关

TestBatchSecondHoptest_batches_e2e.py,LIT-5347)注册一个指向代理自身 base URLlitellm_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_markertest_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_terminaltest_batches_e2e.py)。

成本断言:LIT-5730 的头条

retrieve 一个 completed 的 model-encoded batch 必须写出一条正花费的行,call_typearetrieve_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_idbatch_utils.py)保证了送往提供商的始终是原始/可解析的文件 id。

有意排除的范围

COVERAGE.md 明确划出了两个 out of scope 项:

  1. Unified(managed)batch 的成本由每小时一次的 CheckBatchCost poller 负责(相关开关见 litellm/constants.py 附近的 PROXY_BATCH_POLLING_ENABLED 注释),且对这类 id,终态 DB 状态会让 retrieve 短路。因此终态格子走的是 encoded 路径;poller 的定时行为不适合放进 e2e gate,应归属 tests/test_litellm/proxy/ 下 DI-stub 的代理集成测试。
  2. Gemini(非 Vertex) 的文件内容在上游抛 NotImplementedError,不构成覆盖单元格。

如何运行与阅读这套测试

  • 运行前提:该套件位于 tests/e2e/ 下,标记为 pytest.mark.e2e,需要真实代理实例与真实提供商凭据。从 capabilities.pylitellm_params() 可读出各提供商所需环境变量:OPENAI_API_KEYAZURE_API_BASE / AZURE_API_KEYVERTEXAI_PROJECT / VERTEXAI_CREDENTIALS / GCS_BUCKET_NAMEAWS_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_lifecyclecapabilities.py)按提供商生成——OpenAI 最细(per-scenario + create / retrieve / cancel / list / file_lifecycle 单元格),Bedrock 在 can_cancel / can_list 门控后追加 cancel 与 list 单元格。

小结

这套 Batches e2e 矩阵展示了如何在“24 小时完成窗口”这一硬约束下构建可复现的异步 API 测试:

  1. 能力表驱动参数化,让矩阵中不存在跳过格,缺凭据即硬失败;
  2. ID 形态分类器(managed / model-encoded / raw)把“路由正确性”转化为可断言的字符串结构性质;
  3. 逐端点完整响应断言,把 OpenAI 契约(object 类型、completion_window、created_at、删除回执)逐字段钉死;
  4. 独立栈阶段解决“启动期全局开关无法与其余套件共存”的部署冲突;
  5. 跨运行标记接力把 24 小时尺度的终态与成本回写摊销到多轮运行,并顺带固化了 LIT-4852($0 花费行)与 LIT-5730(成本行丢失)两个缺陷回归。

对维护 LiteLLM 代理的团队,COVERAGE.mdtest_batches_e2e.py 共同构成了 Batches 接口的“活文档”:任何新提供商接入 batches,只需在 PROVIDERS / capabilities.py 中声明一行能力,即可获得整条生命周期、内容下载与成本回归的自动覆盖。

登录后查看全文
热门项目推荐
相关项目推荐