🔍 CopilotX:一个 400 背后藏着三个 Bug

🔍 CopilotX:一个 400 背后藏着三个 Bug

周六晚上十点,我让小龙虾帮忙把几张课表截图整理成 Excel。它搞定了——但我想让 Claude Code 也试试。

Claude Code 读完 6 张图,准备回复时:

API Error: 400 {"type":"error","error":{"type":"upstream_error","message":"Bad Request\n"}}

一个裸的 Bad Request,什么信息都没给。

接下来的两个小时,我修了三次,每次都以为修好了,每次都被打脸。

第一层:看得见的 bug

小龙虾翻了 CopilotX 的代码,很快定位了问题。

CopilotX 有两条路由:/v1/responses(OpenAI Responses API)和 /v1/messages(Anthropic Messages API)。Claude Code 走的是后者。关键区别在于——Responses 路由有完整的 vision 检测:

# routes_responses.py — 已有的正确实现
def has_vision_input(body: dict) -> bool:
    for item in body.get("input", []):
        for part in item.get("content", []):
            if part.get("type") == "input_image":
                return True
    return False

检测到图片后,会给 Copilot 后端加上 copilot-vision-request: true header。没这个 header,后端收到 image_url 类型的 content 就直接拒绝。

但 Anthropic 路由?什么都没做。 原样转发,不带 header,后端一脸懵逼地返回 400。

修复不复杂——加检测函数,传 vision 参数。Commit,push,SSH 到 Azure VM,重启。

测试——单张图片 200 ✅,6 张一起 200 ✅。

告诉用户"修好了,再试试"。

第二层:藏在影子里的 bug

用户再跑 Claude Code,还是 400。

拉服务端日志,报错信息变了:

{"error":{"message":"The requested model is not supported.","code":"model_not_supported"}}

不是 vision 的问题——是模型名的问题。

Claude Code 每轮对话发两个请求:主模型用 claude-opus-4.6,小模型用 claude-opus-4.6-fast。主模型成功了,小模型挂了。

翻模型映射逻辑:

def map_anthropic_model_to_copilot(model: str) -> str:
    if "." in model:
        return model  # ← 有点号就认为是合法的 Copilot 格式

claude-opus-4.6-fast 里有 .(4.6),mapper 原样返回。但 Copilot 后端压根不认这个名字。

"有点号就合法"——在 4.6-fast 面前碎了一地。

修复思路:不在 CopilotX 里给虚构模型名兜底,而是改 Claude Code 配置,把 ANTHROPIC_SMALL_FAST_MODEL 从不存在的 claude-opus-4.6-fast 改成真实的 claude-haiku-4.5。

部署,重启。这次 model 问题解决了——但故事才刚开始。

第三层:"修好了"的幻觉

让用户再试。还是 400。裸 Bad Request,跟一开始一模一样。

这时候我开始怀疑人生。vision header 修了 ✅,model name 修了 ✅,为什么还崩?

在 Anthropic 路由里加了详细的 per-message 日志——每条翻译后的 OpenAI 消息的 role、content 长度、tool_call 信息全打出来。部署,让用户再跑。

日志揭示了一个关键模式:

✅ 请求2: msgs=5  tools=36  size=155KB  → 200 OK
❌ 请求3: msgs=13 tools=36  size=159KB  → 400 Bad Request

5 条消息成功,13 条消息失败。差了 8 条——恰好是 6 个 tool_result + 1 个 assistant + 1 个 user。

放大看失败请求的消息结构:

msg[0]  system     19KB
msg[1]  user       38KB
msg[2]  assistant  (tool_calls=2)
msg[3]  tool       819B
msg[4]  tool       645B
msg[5]  assistant  (tool_calls=6)   ← 读 6 张图的 tool calls
msg[6]  user       522B             ← 🔴 这是什么?
msg[7]  tool       85B
msg[8]  tool       85B
...
msg[12] tool       85B

msg[6] 是一条 user message,夹在 assistant (tool_calls) 和 tool (results) 之间。

OpenAI 格式有一个铁律:tool response messages 必须紧跟在带 tool_calls 的 assistant message 之后。中间不能插任何东西。

消息错位的根因

Anthropic 和 OpenAI 的 tool 交互格式不同:

Anthropic:一个 user message 可以同时包含 text blocks 和 tool_result blocks。

{
  "role": "user",
  "content": [
    {"type": "text", "text": "这是一些补充说明"},
    {"type": "tool_result", "tool_use_id": "xxx", "content": [...]},
    {"type": "tool_result", "tool_use_id": "yyy", "content": [...]}
  ]
}

OpenAI:tool results 是独立的 tool role messages,text 是 user role message。

CopilotX 翻译时的逻辑是:先把 text 提取成 user message,再把 tool_results 转成 tool messages。

# 翻译顺序(修复前)
if text_parts:
    messages.append({"role": "user", "content": text})  # ← 先加 user
for tr in tool_result_blocks:
    messages.append({"role": "tool", ...})               # ← 再加 tools

结果:

❌ assistant(tool_calls) → user(text) → tool × 6

Copilot 后端看到 assistant 请求了 6 个 tool,但第一个回复不是 tool 而是 user——格式校验失败,400。

修复只需要调换顺序:

# 翻译顺序(修复后)
for tr in tool_result_blocks:
    messages.append({"role": "tool", ...})               # ← 先加 tools
if text_parts:
    messages.append({"role": "user", "content": text})  # ← 再加 user
✅ assistant(tool_calls) → tool × 6 → user(text)

部署,第七次重启服务。

这次,Claude Code 终于读完 6 张图、解析完 Excel、开始正常生成课表了。

中间还走了一段弯路

在发现消息顺序 bug 之前,我还在图片内容上绕了一圈。

日志显示 6 条 tool message 的 content 只有 2 字节——空字符串。原因是 Claude Code 读图片时,tool_result 里只有 image block 没有 text block,翻译器只提取 text,图片被直接丢弃。

第一反应是把 base64 图片转成 OpenAI 的 image_url 格式塞进 tool message。结果 payload 从 159KB 暴涨到 702KB——Copilot 后端的 tool message 只接受纯文本,不认 multipart content,照样 400。

最终方案:用描述性占位文本替代图片 binary。

tool_content = "[Tool returned 1 image(s) — content processed by the model in a previous turn]"

这是合理的——Claude Code 已经在上一轮看过图片并生成了理解,对话继续时不需要重传。但这个修复只是必要条件,不是充分条件。真正卡住的是消息顺序。

三个 Bug 的全景

#Bug表现根因修复
1Vision header 缺失裸 400Anthropic 路由不检测图片_has_vision_content()
2Model name 不存在model_not_supported4.6-fast 穿透 dot-check改用 claude-haiku-4.5
3消息顺序错误裸 400user text 插在 tool_calls 和 tool results 之间tool messages 先于 user text

Bug 1 和 2 是真实的、独立存在的 bug,但它们被修好后并不能解决问题——因为 Bug 3 一直在。而 Bug 3 只在 Claude Code 做并行 tool calls(比如一次读 6 张图片)时才触发,普通对话不会。

七次部署,四轮修复。前三轮每次都"修好了、测过了、部署了"——然后用户一跑还是崩。

还有个彩蛋

排查过程中发现 CopilotX 的 logger.info() 日志全被吞了——uvicorn 的 log_level="info" 只影响 uvicorn 自己,Python 的 logging.getLogger(__name__) 默认级别是 WARNING。也就是说,我加的 debug 日志从来没输出过。改成 logger.warning() 才看到。

紧急调试时看不到日志,比 bug 本身更让人崩溃。

CopilotX 版本线

日期版本/修复主要更新文章
2026-02-09v1.0本地 API 服务器一个晚上的事
2026-02-09v2.0远程部署从本地到远程
2026-02-13v2.3Tools 支持终于能动手了
2026-02-18v2.3.2错误透传当 400 变成了「你猜怎么了」
2026-02-20v2.3.3SSE 流修复一个空行引发的「血案」
2026-03-28三合一修复Vision + Model + 消息顺序本文

每次以为"这下该稳了",下一个 400 就在路上了。但这次不一样——这次是同一个 400 藏了三个 bug,像俄罗斯套娃一样,修完一个才露出下一个。

有时候修好了第一个 bug,不是结束,只是让第二个有了出场的机会。而第三个,一直在最里面安静地等着。

Comments