API中转返回200仍报错?检查choices和usage字段

很多接口会把自己描述成“OpenAI 兼容”,但真正接入后才发现,兼容的含义可能只覆盖了 URL 形式,或者只在最简单的请求上返回了 200。客户端一旦依赖choices[0].message.contentusage.total_tokens或错误响应中的固定字段,隐藏的不一致就会变成运行时故障。

这篇文章不做供应商排名,也不把一次成功请求包装成完整兼容性证明。我们把问题收窄成一个可以在本地完成的任务:给定一个请求 JSON 和一个响应 JSON,用一份最小契约检查器验证“客户端真正依赖的字段是否存在、类型是否正确、列表是否为空”。通过样例和失败样例都放在文章配套目录里,读者可以先离线验证,再把同一套检查器接到自己的测试环境。

先用一分钟定位是不是字段兼容问题

适用场景是:你已经把中转站提供的Base URL、API Key 和模型名填进客户端,请求也返回 HTTP 200,但程序仍然出现KeyError: choicesNoneType、用量统计为空,或 SDK 无法读取消息内容。

先不要改业务代码,保存一次原始 JSON 响应,再检查客户端最常读取的三个位置:

python3-mjson.tool response.json python3 contract_check.py request.json response.json

成功信号是脚本输出PASS response contract并以exit=0结束;如果输出明确指向response.choices[0].message.contentresponse.usage.total_tokens,说明问题在响应字段契约,不是“网络已经通了就一定兼容”。如果 HTTP 状态本身是 401、404 或 429,应先按鉴权、路径/模型名和限流分别处理,不要混进字段兼容问题。

实测结果概览

图中结果来自本文配套的本地 fixture:通过样例返回 exit=0,缺少 message.content 的失败样例返回 exit=1。两条路径都由同一检查器实际执行,失败结果没有被改写成成功。

先定义“兼容”的检查范围

OpenAI API 的官方参考页把 Chat Completions 描述为对一组消息发起POST /chat/completions,并列出ChatCompletion响应对象。JSON Schema 官方教程则把 schema 解释为对 JSON 实例的结构、约束和类型描述。把这两份资料放在一起,得到一个很实用的判断:兼容性不是一句产品描述,而是一组可以被验证的字段约束。

本文先检查四个层次:

  1. 请求体是否是对象,model是否为非空字符串,messages是否为非空数组。
  2. 每条消息是否有允许的role和字符串形式的content
  3. 响应体是否包含非空的choices,首个 choice 是否有整数index和 assistant 消息。
  4. usage是否提供三个整数:prompt_tokenscompletion_tokenstotal_tokens

这不是对完整 API 的覆盖。工具调用、图像输入、流式事件、音频、多候选和供应商自定义字段都可能需要额外契约。好处是:先把业务代码最依赖的最小面固定下来,失败时能知道究竟是哪一层不满足。

准备一个不带真实凭据的检查器

下面的检查器只使用 Python 标准库。它不请求网络,不读取环境变量中的 Key,也不把响应内容发送到第三方服务。将它保存为contract_check.py,再传入请求文件和响应文件:

importjsonimportsysfrompathlibimportPathdeffail(path,message):raiseValueError(f"{path}:{message}")defrequire_object(value,path):ifnotisinstance(value,dict):fail(path,"expected object")defrequire_string(value,path):ifnotisinstance(value,str)ornotvalue.strip():fail(path,"expected non-empty string")defrequire_int(value,path):ifnotisinstance(value,int)orisinstance(value,bool):fail(path,"expected integer")defcheck_request(data):require_object(data,"request")require_string(data.get("model"),"request.model")messages=data.get("messages")ifnotisinstance(messages,list)ornotmessages:fail("request.messages","expected non-empty list")allowed_roles={"system","developer","user","assistant"}forindex,messageinenumerate(messages):path=f"request.messages[{index}]"require_object(message,path)ifmessage.get("role")notinallowed_roles:fail(f"{path}.role","unsupported role")require_string(message.get("content"),f"{path}.content")defcheck_response(data):require_object(data,"response")require_string(data.get("id"),"response.id")ifdata.get("object")!="chat.completion":fail("response.object","expected chat.completion")choices=data.get("choices")ifnotisinstance(choices,list)ornotchoices:fail("response.choices","expected non-empty list")first=choices[0]require_object(first,"response.choices[0]")require_int(first.get("index"),"response.choices[0].index")message=first.get("message")require_object(message,"response.choices[0].message")ifmessage.get("role")!="assistant":fail("response.choices[0].message.role","expected assistant")require_string(message.get("content"),"response.choices[0].message.content")usage=data.get("usage")require_object(usage,"response.usage")forkeyin("prompt_tokens","completion_tokens","total_tokens"):require_int(usage.get(key),f"response.usage.{key}")defmain():iflen(sys.argv)!=3:print("usage: python3 contract_check.py REQUEST.json RESPONSE.json")return2request=json.loads(Path(sys.argv[1]).read_text(encoding="utf-8"))response=json.loads(Path(sys.argv[2]).read_text(encoding="utf-8"))try:check_request(request)print("PASS request contract")check_response(response)print("PASS response contract")print("SUMMARY contract=pass")return0except(ValueError,json.JSONDecodeError)asexc:print(f"FAIL{exc}")print("SUMMARY contract=fail")return1if__name__=="__main__":raiseSystemExit(main())

这里有两个刻意的选择。第一,检查器把布尔值排除在整数之外,因为 Python 中boolint的子类,直接使用isinstance(value, int)容易放过错误数据。第二,检查器只验证客户端明确依赖的字段,不会因为服务端增加了额外字段就失败。契约测试应该约束必需行为,而不是把供应商扩展字段全部锁死。

先跑通过路径,再跑失败路径

将下面的请求作为request.json。这只是脱敏 fixture,不含真实用户问题、模型 Key 或线上数据:

{"model":"demo-model","messages":[{"role":"user","content":"Return one short sentence."}]}

对应的响应 fixture 可以是:

{"id":"chatcmpl-demo-001","object":"chat.completion","created":1700000000,"model":"demo-model","choices":[{"index":0,"message":{"role":"assistant","content":"A short sentence."},"finish_reason":"stop"}],"usage":{"prompt_tokens":8,"completion_tokens":4,"total_tokens":12}}

执行模板如下。/v1前缀和真实模型名应由读者替换;本文没有用真实 Key 执行这条线上请求,因此下面只展示“把线上响应保存后再检查”的方法:

BASE_URL="https://example.com/v1"curl-sS"$BASE_URL/chat/completions"\-H"Content-Type: application/json"\-H"Authorization: Bearer$OPENAI_API_KEY"\-d@request.json>response.json python3 contract_check.py request.json response.json

本次实际执行的是本地 fixture:

PASS request contract PASS response contract SUMMARY contract=pass

然后把message.content删除,模拟一个 HTTP 请求已经返回、但客户端真正需要的字段不存在的响应。实际失败信号是:

FAIL response.choices[0].message.content: expected non-empty string SUMMARY contract=fail

这比只检查 HTTP 200 更有价值。200 只能说明网络层和服务端愿意返回一个响应,不能说明响应能被下游代码消费。失败路径应进入测试报告,而不是在业务代码里继续用response["choices"][0]触发一个难定位的KeyErrorTypeError

如何区分鉴权失败和契约失败

契约测试最好分成两段。第一段验证鉴权和连通性:状态码、响应头、错误对象是否符合你们的错误处理约定。第二段只对成功响应执行字段契约检查。这样,401/403 不会被错误地报告为“响应格式不兼容”,而是明确归类为凭据、权限或环境问题。

我在 2026-07-17 对 CODELINK 的公开/v1/models做了无凭据只读检查,页面返回API_KEY_REQUIRED,并说明需要在 Authorization、x-api-keyx-goog-api-key中提供认证信息。这次结果只证明鉴权边界。由于没有获得测试 Key,本文不把它写成成功调用,也不推断当前模型列表、性能或稳定性。

失败排查顺序

建议按下面顺序排查,避免一上来就改 SDK:

  1. 状态码先看清楚。401/403 先查环境变量是否加载、Key 是否属于当前环境、权限是否足够;429 需要记录限流信息;5xx 则保留请求时间和服务端错误 ID。
  2. 再检查 JSON 顶层。python3 -m json.tool response.json确认响应不是 HTML 错误页或网关文本。
  3. 再执行字段契约。确认choices非空,首个消息角色和内容存在,usage字段类型正确。
  4. 最后才看扩展能力。如果基础契约通过,再单独为流式、工具调用、图像和供应商扩展增加测试,不把所有能力混在一次失败里。

安全边界和适用范围

不要把真实 Key 写进 fixture、截图、命令历史或文章正文。示例只使用demo-model和固定文本;真实请求通过环境变量读取 Key,并且只在后端或受控终端执行。文章配套文件不应包含请求头、真实用户输入、响应中的个人信息或可复用的邀请参数。

契约测试不能证明模型回答质量,也不能证明长时间稳定性、价格或并发上限。它解决的是更基础的问题:当客户端要读取某个字段时,接口是否返回了符合约定的结构。把这个检查放进 CI 或发布前手工清单,能够让“兼容”从宣传语变成可比较、可回归的测试结果。

本文的测试环境之一是作者维护的 CODELINK API 中转服务。这个关系披露不改变教程结论;删除该服务名称后,契约检查器、fixture 和排错方法仍然可以独立运行。

总结

OpenAI 兼容 API 的第一步不是选择哪个 SDK,而是写出你真正依赖的最小契约。用 JSON Schema 的思路描述结构,再用一份不依赖第三方包的检查器执行,通过和失败都保存下来,至少可以回答三个问题:请求是否满足前置约束、响应是否真的能被客户端消费、失败属于鉴权问题还是结构问题。

当这三件事有了可重复的成功信号和失败路径,后续再扩展流式事件、工具调用和性能测试,才不会把不同层次的问题混成一句“接口不兼容”。