晚上六点半,我让小龙虾(我的 AI 助手)帮我测试一下 CopilotX,看看还有什么不足。
它回来交了一份 18 项测试报告,17 项全过,1 项标红。但让我真正动手改代码的,不是那个红色的 ❌——而是红色背后那句毫无信息量的错误信息。
起因:让 AI 测试 AI 的 API
故事的背景是这样的:CopilotX 是我写的一个 API 代理,把 GitHub Copilot 的订阅变成标准的 OpenAI / Anthropic 兼容 API。v2.3.0 刚解决了 tool calling 的问题,小龙虾终于能动手写文件了。
今天折腾完 OpenClaw 的版本升级后,我想——既然 CopilotX 是小龙虾的「脊柱」,不如让它自己来做一次全面体检。
于是我说:
帮我完整测试一波 CopilotX,看看还有什么不足
小龙虾真的跑了 18 个端到端测试,覆盖了我都没想到的边界场景:
| # | 测试项 | 结果 |
|---|---|---|
| 1 | Health Check | ✅ |
| 2 | 模型列表(25 个模型) | ✅ |
| 3 | OpenAI Chat(非流式) | ✅ |
| 4 | OpenAI Chat(流式) | ✅ |
| 5 | Anthropic Messages(非流式) | ✅ |
| 6 | Anthropic Messages(流式) | ✅ |
| 7 | Anthropic Tool Use(非流式) | ✅ |
| 8 | Anthropic Tool Use(流式) | ✅ |
| 9 | 多轮工具对话(tool_result) | ✅ |
| 10 | System 为内容块列表 | ✅ |
| 11 | Responses API | ❌ 400 |
| 12 | OpenAI Chat with Tools | ✅ |
| 13 | 多工具并行调用 | ✅ |
| 14 | tool_choice=any | ✅ |
| 15 | tool_choice=specific | ✅ |
| 16-17 | 认证拦截(缺失/错误 key) | ✅ |
| 18 | 旧版 model 名映射 | ✅ |
17/18 通过,核心功能很稳。但那个 Responses API 的 400 错误,返回的信息是这样的:
{
"error": {
"message": "Copilot backend error: Client error '400 Bad Request' for url 'https://api.enterprise.githubcopilot.com/responses'",
"type": "upstream_error"
}
}
就这? 后端明明返回了具体的错误原因,但 CopilotX 只告诉我「400 Bad Request」。这就像医生告诉你"你有病"但不说是什么病一样。
根因:raise_for_status() 的罪
问题出在 client.py 里四个 HTTP 请求方法:
async def chat_completions(self, payload: dict) -> dict:
resp = await self._client.post(url, json=payload, headers=self._headers())
resp.raise_for_status() # ← 这行是罪魁祸首
return resp.json()
httpx.raise_for_status() 在遇到 4xx/5xx 响应时,直接抛出一个 HTTPStatusError 异常。异常信息只包含 HTTP 状态码和 URL——后端返回的响应体(error body)被完全丢弃了。
然后在路由层,except Exception as e 把这个残缺的异常信息包装成 502 返回给客户端:
except Exception as e:
return JSONResponse(
status_code=502, # 永远是 502
content={"error": {"message": f"Copilot backend error: {e}"}}
)
两个问题叠加:
- 错误体丢失:
raise_for_status()不保留 response body - 状态码篡改:后端返回 400,客户端看到 502
修复:四层错误透传
小龙虾先写了第一版修复,我在它的基础上补充了两点。最终的改动覆盖四个层面:
第一层:非流式请求读取错误体
之前:
resp = await self._client.post(url, json=payload, headers=self._headers())
resp.raise_for_status() # 盲抛,丢弃 body
之后:
resp = await self._client.post(url, json=payload, headers=self._headers())
if resp.status_code >= 400:
error_body = resp.text
logger.error("Chat completions error: status=%d body=%s",
resp.status_code, error_body[:1000])
raise httpx.HTTPStatusError(
f"HTTP {resp.status_code}: {error_body[:500]}",
request=resp.request,
response=resp, # 保留完整 response 对象
)
关键变化:先读 resp.text,再构造异常。这样上游 catch 到异常时,还能通过 e.response.text 拿到完整错误体。
第二层:流式请求的错误处理
这是我补充的第一点。小龙虾的第一版只改了非流式方法,但流式请求有个微妙的区别——在 httpx.stream() 上下文里,response body 还没被读取,必须用 await resp.aread() 显式读取:
async with self._client.stream("POST", url, json=payload, ...) as resp:
if resp.status_code >= 400:
error_body = await resp.aread() # 流式上下文里必须 aread()
logger.error("Stream error: status=%d body=%s",
resp.status_code, error_body[:1000])
raise httpx.HTTPStatusError(
f"HTTP {resp.status_code}: {error_body.decode('utf-8', errors='replace')[:500]}",
request=resp.request, response=resp,
)
如果不用 aread() 而是直接读 resp.text,你拿到的是空字符串。
第三层:路由层透传真实状态码
路由层不再一律返回 502,而是从异常中提取后端的真实状态码:
except Exception as e:
status_code = 502
error_content = {"error": {"message": f"Copilot backend error: {e}", ...}}
if hasattr(e, 'response') and e.response is not None:
status_code = e.response.status_code # 透传真实状态码
try:
error_content = json.loads(e.response.text) # 透传 JSON 错误体
except (json.JSONDecodeError, ValueError):
error_content["error"]["message"] = e.response.text[:500]
return JSONResponse(status_code=status_code, content=error_content)
第四层:JSON 错误体解析
这是我补充的第二点。后端的错误信息通常是 JSON 格式的,比如:
{"error": {"message": "Model xxx not supported for Responses API", "code": "invalid_model"}}
之前是字符串拼接 "Copilot backend error: {error_msg}",现在尝试 json.loads() 解析,成功了就直接转发后端的原始 JSON。客户端看到的就是后端想说的话,一字不差。
破案:Responses API 的 400 真相
改完代码后,我重新测试了 Responses API,这次用对了模型:
# ❌ Claude 模型 → 400
curl -X POST https://api.polly.wang/v1/responses \
-d '{"model": "claude-sonnet-4", "input": "hello"}'
# ✅ GPT-5 模型 → 正常
curl -X POST https://api.polly.wang/v1/responses \
-d '{"model": "gpt-5-mini", "input": "2+2=?"}'
{
"output": [
{"type": "reasoning", "summary": []},
{"content": [{"text": "2 + 2 = 4", "type": "output_text"}],
"type": "message", "status": "completed"}
]
}
原来 Responses API 只支持 GPT-5 系列模型——Claude 系列走的是 Chat Completions + Anthropic 翻译。这不是 CopilotX 的 bug,是 Copilot 后端的限制。但以前的错误信息完全没告诉我这一点,只说了一句「400 Bad Request」。
现在改完之后,如果你用错了模型,能看到后端的完整错误信息。这才是一个 API 代理该做的事——透明地转发,包括错误。
人虾协作
这次的改动很有意思——它是一个人机协作的过程:
- 小龙虾跑了 18 项自动化测试,发现了 Responses API 的问题
- 小龙虾写了第一版修复:非流式请求的错误体读取 + 路由层状态码透传
- 我审了它的 diff,补充了两点:
- 流式请求的
aread()处理(它漏掉了) - JSON 错误体解析(从字符串拼接升级到结构化透传)
- 流式请求的
- 小龙虾 review 了最终改动,确认完善
最终改动量:4 个文件,+112 行。
| 文件 | 改动 |
|---|---|
client.py | 非流式 + 流式请求都捕获完整错误体 |
routes_openai.py | 加 logging + JSON 错误体解析 |
routes_anthropic.py | 加 json import + JSON 错误体解析 |
routes_responses.py | JSON 错误体解析 |
版本线
从 2 月 9 日到今天,CopilotX 的进化轨迹:
| 日期 | 版本 | 里程碑 |
|---|---|---|
| 02/09 | v1.0 | 一个晚上,本地 API 服务器 |
| 02/09 | v2.0 | 远程部署 + API Key 认证 |
| 02/11 | — | 让 OpenClaw 跑起来 |
| 02/13 | v2.3 | Tool Calling 完整支持 |
| 02/18 | v2.3.2 | 错误透传 + 流式错误处理 |
每一个版本都是解决一个实际痛点——不是为了加功能而加功能,而是用着用着发现「这里不对」,然后改好它。
经验
- 让 AI 测 AI 的基础设施——小龙虾测 CopilotX 比我手动 curl 效率高得多,它一口气跑完 18 个场景还自动分类总结
- 错误信息是 API 的灵魂——proxy 的职责是透明,包括透明地转发错误。吞掉错误体是最容易犯也最难排查的问题
- 流式和非流式是两个世界——
resp.textvsawait resp.aread(),一个字的差别,结果天差地别 - 先让它说话,再修它的病——改进错误处理 → 看到真正的错误 → 发现根本不是 bug 而是模型限制。如果还是 502 一片糊涂,我可能会花几个小时改一个不存在的 bug
下一步:小龙虾已经跑稳了,CopilotX 也稳了。是时候让它做点更有趣的事情了。🦞

