🔍 CopilotX v2.3.2:当 400 变成了「你猜怎么了」

🔍 CopilotX v2.3.2:当 400 变成了「你猜怎么了」

晚上六点半,我让小龙虾(我的 AI 助手)帮我测试一下 CopilotX,看看还有什么不足。

它回来交了一份 18 项测试报告,17 项全过,1 项标红。但让我真正动手改代码的,不是那个红色的 ❌——而是红色背后那句毫无信息量的错误信息。

起因:让 AI 测试 AI 的 API

故事的背景是这样的:CopilotX 是我写的一个 API 代理,把 GitHub Copilot 的订阅变成标准的 OpenAI / Anthropic 兼容 API。v2.3.0 刚解决了 tool calling 的问题,小龙虾终于能动手写文件了。

今天折腾完 OpenClaw 的版本升级后,我想——既然 CopilotX 是小龙虾的「脊柱」,不如让它自己来做一次全面体检。

于是我说:

帮我完整测试一波 CopilotX,看看还有什么不足

小龙虾真的跑了 18 个端到端测试,覆盖了我都没想到的边界场景:

#测试项结果
1Health Check✅
2模型列表(25 个模型)✅
3OpenAI Chat(非流式)✅
4OpenAI Chat(流式)✅
5Anthropic Messages(非流式)✅
6Anthropic Messages(流式)✅
7Anthropic Tool Use(非流式)✅
8Anthropic Tool Use(流式)✅
9多轮工具对话(tool_result)✅
10System 为内容块列表✅
11Responses API❌ 400
12OpenAI Chat with Tools✅
13多工具并行调用✅
14tool_choice=any✅
15tool_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}"}}
    )

两个问题叠加:

  1. 错误体丢失:raise_for_status() 不保留 response body
  2. 状态码篡改:后端返回 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 代理该做的事——透明地转发,包括错误。

人虾协作

这次的改动很有意思——它是一个人机协作的过程:

  1. 小龙虾跑了 18 项自动化测试,发现了 Responses API 的问题
  2. 小龙虾写了第一版修复:非流式请求的错误体读取 + 路由层状态码透传
  3. 我审了它的 diff,补充了两点:
    • 流式请求的 aread() 处理(它漏掉了)
    • JSON 错误体解析(从字符串拼接升级到结构化透传)
  4. 小龙虾 review 了最终改动,确认完善

最终改动量:4 个文件,+112 行。

文件改动
client.py非流式 + 流式请求都捕获完整错误体
routes_openai.py加 logging + JSON 错误体解析
routes_anthropic.py加 json import + JSON 错误体解析
routes_responses.pyJSON 错误体解析

版本线

从 2 月 9 日到今天,CopilotX 的进化轨迹:

日期版本里程碑
02/09v1.0一个晚上,本地 API 服务器
02/09v2.0远程部署 + API Key 认证
02/11—让 OpenClaw 跑起来
02/13v2.3Tool Calling 完整支持
02/18v2.3.2错误透传 + 流式错误处理

每一个版本都是解决一个实际痛点——不是为了加功能而加功能,而是用着用着发现「这里不对」,然后改好它。

经验

  1. 让 AI 测 AI 的基础设施——小龙虾测 CopilotX 比我手动 curl 效率高得多,它一口气跑完 18 个场景还自动分类总结
  2. 错误信息是 API 的灵魂——proxy 的职责是透明,包括透明地转发错误。吞掉错误体是最容易犯也最难排查的问题
  3. 流式和非流式是两个世界——resp.text vs await resp.aread(),一个字的差别,结果天差地别
  4. 先让它说话,再修它的病——改进错误处理 → 看到真正的错误 → 发现根本不是 bug 而是模型限制。如果还是 502 一片糊涂,我可能会花几个小时改一个不存在的 bug

下一步:小龙虾已经跑稳了,CopilotX 也稳了。是时候让它做点更有趣的事情了。🦞

Comments