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

API中转返回200仍报错?检查choices和usage字段 很多接口会把自己描述成“OpenAI 兼容”但真正接入后才发现兼容的含义可能只覆盖了 URL 形式或者只在最简单的请求上返回了 200。客户端一旦依赖choices[0].message.content、usage.total_tokens或错误响应中的固定字段隐藏的不一致就会变成运行时故障。这篇文章不做供应商排名也不把一次成功请求包装成完整兼容性证明。我们把问题收窄成一个可以在本地完成的任务给定一个请求 JSON 和一个响应 JSON用一份最小契约检查器验证“客户端真正依赖的字段是否存在、类型是否正确、列表是否为空”。通过样例和失败样例都放在文章配套目录里读者可以先离线验证再把同一套检查器接到自己的测试环境。先用一分钟定位是不是字段兼容问题适用场景是你已经把中转站提供的Base URL、API Key 和模型名填进客户端请求也返回 HTTP 200但程序仍然出现KeyError: choices、NoneType、用量统计为空或 SDK 无法读取消息内容。先不要改业务代码保存一次原始 JSON 响应再检查客户端最常读取的三个位置python3-mjson.tool response.json python3 contract_check.py request.json response.json成功信号是脚本输出PASS response contract并以exit0结束如果输出明确指向response.choices[0].message.content或response.usage.total_tokens说明问题在响应字段契约不是“网络已经通了就一定兼容”。如果 HTTP 状态本身是 401、404 或 429应先按鉴权、路径/模型名和限流分别处理不要混进字段兼容问题。实测结果概览图中结果来自本文配套的本地 fixture通过样例返回 exit0缺少 message.content 的失败样例返回 exit1。两条路径都由同一检查器实际执行失败结果没有被改写成成功。先定义“兼容”的检查范围OpenAI API 的官方参考页把 Chat Completions 描述为对一组消息发起POST /chat/completions并列出ChatCompletion响应对象。JSON Schema 官方教程则把 schema 解释为对 JSON 实例的结构、约束和类型描述。把这两份资料放在一起得到一个很实用的判断兼容性不是一句产品描述而是一组可以被验证的字段约束。本文先检查四个层次请求体是否是对象model是否为非空字符串messages是否为非空数组。每条消息是否有允许的role和字符串形式的content。响应体是否包含非空的choices首个 choice 是否有整数index和 assistant 消息。usage是否提供三个整数prompt_tokens、completion_tokens、total_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)messagesdata.get(messages)ifnotisinstance(messages,list)ornotmessages:fail(request.messages,expected non-empty list)allowed_roles{system,developer,user,assistant}forindex,messageinenumerate(messages):pathfrequest.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)choicesdata.get(choices)ifnotisinstance(choices,list)ornotchoices:fail(response.choices,expected non-empty list)firstchoices[0]require_object(first,response.choices[0])require_int(first.get(index),response.choices[0].index)messagefirst.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)usagedata.get(usage)require_object(usage,response.usage)forkeyin(prompt_tokens,completion_tokens,total_tokens):require_int(usage.get(key),fresponse.usage.{key})defmain():iflen(sys.argv)!3:print(usage: python3 contract_check.py REQUEST.json RESPONSE.json)return2requestjson.loads(Path(sys.argv[1]).read_text(encodingutf-8))responsejson.loads(Path(sys.argv[2]).read_text(encodingutf-8))try:check_request(request)print(PASS request contract)check_response(response)print(PASS response contract)print(SUMMARY contractpass)return0except(ValueError,json.JSONDecodeError)asexc:print(fFAIL{exc})print(SUMMARY contractfail)return1if__name____main__:raiseSystemExit(main())这里有两个刻意的选择。第一检查器把布尔值排除在整数之外因为 Python 中bool是int的子类直接使用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_URLhttps://example.com/v1curl-sS$BASE_URL/chat/completions\-HContent-Type: application/json\-HAuthorization: Bearer$OPENAI_API_KEY\-drequest.jsonresponse.json python3 contract_check.py request.json response.json本次实际执行的是本地 fixturePASS request contract PASS response contract SUMMARY contractpass然后把message.content删除模拟一个 HTTP 请求已经返回、但客户端真正需要的字段不存在的响应。实际失败信号是FAIL response.choices[0].message.content: expected non-empty string SUMMARY contractfail这比只检查 HTTP 200 更有价值。200 只能说明网络层和服务端愿意返回一个响应不能说明响应能被下游代码消费。失败路径应进入测试报告而不是在业务代码里继续用response[choices][0]触发一个难定位的KeyError或TypeError。如何区分鉴权失败和契约失败契约测试最好分成两段。第一段验证鉴权和连通性状态码、响应头、错误对象是否符合你们的错误处理约定。第二段只对成功响应执行字段契约检查。这样401/403 不会被错误地报告为“响应格式不兼容”而是明确归类为凭据、权限或环境问题。我在 2026-07-17 对 CODELINK 的公开/v1/models做了无凭据只读检查页面返回API_KEY_REQUIRED并说明需要在 Authorization、x-api-key或x-goog-api-key中提供认证信息。这次结果只证明鉴权边界。由于没有获得测试 Key本文不把它写成成功调用也不推断当前模型列表、性能或稳定性。失败排查顺序建议按下面顺序排查避免一上来就改 SDK状态码先看清楚。401/403 先查环境变量是否加载、Key 是否属于当前环境、权限是否足够429 需要记录限流信息5xx 则保留请求时间和服务端错误 ID。再检查 JSON 顶层。用python3 -m json.tool response.json确认响应不是 HTML 错误页或网关文本。再执行字段契约。确认choices非空首个消息角色和内容存在usage字段类型正确。最后才看扩展能力。如果基础契约通过再单独为流式、工具调用、图像和供应商扩展增加测试不把所有能力混在一次失败里。安全边界和适用范围不要把真实 Key 写进 fixture、截图、命令历史或文章正文。示例只使用demo-model和固定文本真实请求通过环境变量读取 Key并且只在后端或受控终端执行。文章配套文件不应包含请求头、真实用户输入、响应中的个人信息或可复用的邀请参数。契约测试不能证明模型回答质量也不能证明长时间稳定性、价格或并发上限。它解决的是更基础的问题当客户端要读取某个字段时接口是否返回了符合约定的结构。把这个检查放进 CI 或发布前手工清单能够让“兼容”从宣传语变成可比较、可回归的测试结果。本文的测试环境之一是作者维护的 CODELINK API 中转服务。这个关系披露不改变教程结论删除该服务名称后契约检查器、fixture 和排错方法仍然可以独立运行。总结OpenAI 兼容 API 的第一步不是选择哪个 SDK而是写出你真正依赖的最小契约。用 JSON Schema 的思路描述结构再用一份不依赖第三方包的检查器执行通过和失败都保存下来至少可以回答三个问题请求是否满足前置约束、响应是否真的能被客户端消费、失败属于鉴权问题还是结构问题。当这三件事有了可重复的成功信号和失败路径后续再扩展流式事件、工具调用和性能测试才不会把不同层次的问题混成一句“接口不兼容”。